hl7-net 0.1.0

Lightweight HL7 V2 parser/writer, ported from the Efferent HL7-V2 .NET library
Documentation
//! Utility functions for splitting, framing and date handling of HL7 messages.

use std::sync::LazyLock;

use jiff::civil::DateTime;
use jiff::{SignedDuration, Timestamp, Zoned};
use regex::Regex;

use crate::error::Hl7Error;

/// Recognized line separators, in priority order (longest first).
const LINE_SEPARATORS: [&str; 4] = ["\r\n", "\n\r", "\r", "\n"];

/// HL7 date/time grammar: `YYYY[MM[DD[HH[MM[SS[.FFFF]]]]]][+/-ZZZZ]`.
static DATETIME_REGEX: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(
        r"^\s*((?:18|19|20)[0-9]{2})(?:(1[0-2]|0[1-9])(?:(3[0-1]|[1-2][0-9]|0[1-9])(?:([0-1][0-9]|2[0-3])(?:([0-5][0-9])(?:([0-5][0-9](?:\.[0-9]{1,4})?)?)?)?)?)?)?(?:([+-][0-1][0-9]|[+-]2[0-3])([0-5][0-9]))?\s*$",
    )
    .expect("valid datetime regex")
});

/// MLLP frame: `<VT> ... <FS><CR>`.
static MLLP_REGEX: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"(?s)\x0B(.*?)\x1C\x0D").expect("valid MLLP regex"));

/// Splits an HL7 message into its segment lines, dropping blank/whitespace lines.
pub fn split_message(message: &str) -> Vec<String> {
    let chars: Vec<char> = message.chars().collect();
    let mut result = Vec::new();
    let mut current = String::new();
    let mut i = 0;

    while i < chars.len() {
        let mut matched = None;

        for sep in LINE_SEPARATORS {
            let sc: Vec<char> = sep.chars().collect();
            if i + sc.len() <= chars.len() && chars[i..i + sc.len()] == sc[..] {
                matched = Some(sc.len());
                break;
            }
        }

        if let Some(len) = matched {
            result.push(std::mem::take(&mut current));
            i += len;
        } else {
            current.push(chars[i]);
            i += 1;
        }
    }

    result.push(current);
    result.into_iter().filter(|s| !s.trim().is_empty()).collect()
}

/// Formats a civil date/time as an HL7 long date with up to four fractional
/// digits (`yyyyMMddHHmmss.FFFF`, trailing zeros and an all-zero fraction omitted).
pub fn long_date_with_fraction_of_second(dt: DateTime) -> String {
    let base = format!(
        "{:04}{:02}{:02}{:02}{:02}{:02}",
        dt.year(),
        dt.month(),
        dt.day(),
        dt.hour(),
        dt.minute(),
        dt.second(),
    );

    let frac = dt.subsec_nanosecond() / 100_000; // 0..=9999

    if frac == 0 {
        base
    } else {
        let digits = format!("{frac:04}");
        let trimmed = digits.trim_end_matches('0');
        format!("{base}.{trimmed}")
    }
}

/// The current local time formatted as an HL7 long date.
pub fn now_long_date() -> String {
    long_date_with_fraction_of_second(Zoned::now().datetime())
}

/// A parsed HL7 date/time plus its timezone offset (in seconds).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Hl7DateTime {
    /// The wall-clock date/time, with no timezone applied.
    pub naive: DateTime,
    /// Timezone offset in seconds, as encoded in the original string.
    pub offset_seconds: i64,
}

impl Hl7DateTime {
    /// Interprets the parsed wall-clock time as UTC and applies the offset,
    /// yielding an absolute timestamp.
    pub fn to_utc(&self) -> Result<Timestamp, Hl7Error> {
        let ts = self
            .naive
            .to_zoned(jiff::tz::TimeZone::UTC)
            .map_err(|e| Hl7Error::new(e.to_string()))?
            .timestamp();

        Ok(ts - SignedDuration::from_secs(self.offset_seconds))
    }
}

/// Parses an HL7 date/time string into its wall-clock value and offset.
pub fn parse_date_time(date_time_string: &str) -> Result<Hl7DateTime, Hl7Error> {
    let caps = DATETIME_REGEX
        .captures(date_time_string)
        .ok_or_else(|| Hl7Error::new("Invalid date format"))?;

    let group_i64 = |i: usize, default: i64| -> Result<i64, Hl7Error> {
        match caps.get(i) {
            Some(m) => m.as_str().parse::<i64>().map_err(|_| Hl7Error::new("Invalid date format")),
            None => Ok(default),
        }
    };

    let year: i16 = group_i64(1, 0)? as i16;
    let month = group_i64(2, 1)? as i8;
    let day = group_i64(3, 1)? as i8;
    let hours = group_i64(4, 0)? as i8;
    let mins = group_i64(5, 0)? as i8;

    let secs: f64 = match caps.get(6) {
        Some(m) => m.as_str().parse::<f64>().map_err(|_| Hl7Error::new("Invalid date format"))?,
        None => 0.0,
    };

    let tzh = group_i64(7, 0)?;
    let tzm = group_i64(8, 0)?;
    let offset_seconds = tzh * 3600 + tzm * 60;

    let whole_secs = secs.trunc() as i8;
    let nanos = ((secs - secs.trunc()) * 1_000_000_000.0).round() as i32;

    let naive = DateTime::new(year, month, day, hours, mins, whole_secs, nanos)
        .map_err(|e| Hl7Error::new(e.to_string()))?;

    Ok(Hl7DateTime { naive, offset_seconds })
}

/// Extracts the HL7 messages framed inside MLLP delimiters from `messages`.
pub fn extract_messages(messages: &str) -> Vec<String> {
    MLLP_REGEX
        .captures_iter(messages)
        .map(|c| c[1].to_string())
        .collect()
}

/// Wraps an HL7 message in an MLLP frame (`<VT> message <FS><CR>`).
pub fn get_mllp(message: &str) -> Vec<u8> {
    let data = message.as_bytes();
    let mut buffer = Vec::with_capacity(data.len() + 3);
    buffer.push(11); // VT
    buffer.extend_from_slice(data);
    buffer.push(28); // FS
    buffer.push(13); // CR
    buffer
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Mixed CR/LF/CRLF separators all split, and blank lines are dropped.
    #[test]
    fn splits_on_mixed_line_separators() {
        let lines = split_message("AAA|1\r\nBBB|2\n\nCCC|3\r");
        assert_eq!(lines, vec!["AAA|1", "BBB|2", "CCC|3"]);
    }

    /// A full timestamp with fraction and offset parses, and `to_utc` applies it.
    #[test]
    fn parses_full_datetime_with_offset() {
        let dt = parse_date_time("20200101120000.5+0100").unwrap();
        assert_eq!(dt.naive.year(), 2020);
        assert_eq!(dt.naive.month(), 1);
        assert_eq!(dt.naive.day(), 1);
        assert_eq!(dt.naive.hour(), 12);
        assert_eq!(dt.naive.subsec_nanosecond(), 500_000_000);
        assert_eq!(dt.offset_seconds, 3600);
        // 12:00:00.5 interpreted as UTC, minus +0100 offset => 11:00:00.5 UTC.
        let utc = dt.to_utc().unwrap();
        assert_eq!(utc.to_string(), "2020-01-01T11:00:00.5Z");
    }

    /// A date-only value parses with a zero offset.
    #[test]
    fn parses_date_only() {
        let dt = parse_date_time("19610615").unwrap();
        assert_eq!(dt.naive.year(), 1961);
        assert_eq!(dt.naive.month(), 6);
        assert_eq!(dt.naive.day(), 15);
        assert_eq!(dt.offset_seconds, 0);
    }

    /// A non-date string is rejected.
    #[test]
    fn rejects_bad_datetime() {
        assert!(parse_date_time("not-a-date").is_err());
    }

    /// The long-date formatter omits an all-zero fraction and trims trailing zeros.
    #[test]
    fn long_date_omits_zero_fraction() {
        let dt = jiff::civil::date(2020, 1, 2).at(3, 4, 5, 0);
        assert_eq!(long_date_with_fraction_of_second(dt), "20200102030405");
        let dt = jiff::civil::date(2020, 1, 2).at(3, 4, 5, 120_000_000);
        assert_eq!(long_date_with_fraction_of_second(dt), "20200102030405.12");
    }

    /// Two MLLP-framed messages in one stream are extracted separately.
    #[test]
    fn extracts_mllp_framed_messages() {
        let stream = "\x0BMSH|^~\\&|A\rPID|1\r\x1C\x0D\x0BMSH|^~\\&|B\r\x1C\x0D";
        let msgs = extract_messages(stream);
        assert_eq!(msgs.len(), 2);
        assert!(msgs[0].starts_with("MSH|^~\\&|A"));
        assert!(msgs[1].starts_with("MSH|^~\\&|B"));
    }
}