#![warn(missing_docs, clippy::pedantic)]
#[cfg(feature = "ack")]
pub mod ack;
mod framer;
mod transport;
#[cfg(feature = "ack")]
pub use ack::AckCode;
pub use framer::{Framer, Tolerance};
pub use transport::{IoTransport, Transport};
#[cfg(feature = "ack")]
pub use hl7_2;
use std::fmt;
pub const START_BLOCK: u8 = 0x0B;
pub const END_BLOCK: u8 = 0x1C;
pub const CARRIAGE_RETURN: u8 = 0x0D;
pub const DEFAULT_LIMIT: usize = 16 * 1024 * 1024;
#[must_use]
pub fn encode(payload: &[u8]) -> Vec<u8> {
let mut frame = Vec::with_capacity(payload.len() + 3);
frame.push(START_BLOCK);
frame.extend_from_slice(payload);
frame.push(END_BLOCK);
frame.push(CARRIAGE_RETURN);
frame
}
#[must_use]
pub fn is_framable(payload: &[u8]) -> bool {
!payload
.iter()
.any(|&byte| byte == START_BLOCK || byte == END_BLOCK)
}
pub fn decode(frame: &[u8]) -> Result<&[u8], Error> {
decode_with(frame, Tolerance::default())
}
pub fn decode_with(frame: &[u8], tolerance: Tolerance) -> Result<&[u8], Error> {
let Some((&first, rest)) = frame.split_first() else {
return Err(Error::Incomplete);
};
if first != START_BLOCK {
return Err(Error::NoStartBlock);
}
let Some(end) = rest.iter().position(|&byte| byte == END_BLOCK) else {
return Err(Error::Incomplete);
};
let payload = &rest[..end];
if payload.contains(&START_BLOCK) {
return Err(Error::EmbeddedStartBlock);
}
match &rest[end + 1..] {
[CARRIAGE_RETURN] => Ok(payload),
[] if tolerance.allows_missing_carriage_return() => Ok(payload),
[] => Err(Error::Incomplete),
[CARRIAGE_RETURN, extra @ ..] => Err(Error::TrailingBytes(extra.len())),
extra if tolerance.allows_missing_carriage_return() => {
Err(Error::TrailingBytes(extra.len()))
}
_ => Err(Error::NoCarriageReturn),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
NoStartBlock,
NoCarriageReturn,
Incomplete,
TrailingBytes(usize),
EmbeddedStartBlock,
LeadingBytes(usize),
TooLarge {
buffered: usize,
limit: usize,
},
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::NoStartBlock => write!(f, "frame does not begin with the MLLP start block"),
Error::NoCarriageReturn => {
write!(f, "the MLLP end block is not followed by a carriage return")
}
Error::Incomplete => write!(f, "frame is incomplete: no end block yet"),
Error::TrailingBytes(count) => {
write!(
f,
"{count} byte(s) follow the frame without beginning another"
)
}
Error::EmbeddedStartBlock => {
write!(
f,
"a start block inside the payload makes the frame ambiguous"
)
}
Error::LeadingBytes(count) => write!(f, "{count} byte(s) arrived outside any frame"),
Error::TooLarge { buffered, limit } => write!(
f,
"buffered {buffered} bytes without a complete frame, over the {limit}-byte limit"
),
}
}
}
impl std::error::Error for Error {}
impl From<Error> for std::io::Error {
fn from(error: Error) -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::InvalidData, error)
}
}
#[cfg(test)]
mod tests {
use super::*;
const MESSAGE: &str = "MSH|^~\\&|LAB|ACME|EHR|CLINIC|20260814080000||ORU^R01|99|P|2.5\rPID|1";
#[test]
fn wraps_and_unwraps_a_message() {
let frame = encode(MESSAGE.as_bytes());
assert_eq!(frame[0], START_BLOCK);
assert_eq!(frame[frame.len() - 2], END_BLOCK);
assert_eq!(frame[frame.len() - 1], CARRIAGE_RETURN);
assert_eq!(decode(&frame).unwrap(), MESSAGE.as_bytes());
}
#[test]
fn leaves_the_payload_exactly_as_it_was() {
assert_eq!(decode(&encode(b"A\rB\r")).unwrap(), b"A\rB\r");
assert_eq!(decode(&encode(b"")).unwrap(), b"");
assert_eq!(decode(&encode(&[0u8, 255, 128])).unwrap(), &[0u8, 255, 128]);
}
#[test]
fn refuses_framing_that_is_not_framing() {
fn strict(bytes: &[u8]) -> Result<&[u8], Error> {
decode_with(bytes, Tolerance::strict())
}
assert_eq!(strict(b""), Err(Error::Incomplete));
assert_eq!(strict(b"MSH|"), Err(Error::NoStartBlock));
assert_eq!(strict(b"\x0bMSH|"), Err(Error::Incomplete));
assert_eq!(strict(b"\x0bMSH|\x1c"), Err(Error::Incomplete));
assert_eq!(strict(b"\x0bMSH|\x1cX"), Err(Error::NoCarriageReturn));
assert_eq!(strict(b"\x0bMSH|\x1c\rextra"), Err(Error::TrailingBytes(5)));
assert_eq!(strict(b"\x0bA\x0bB\x1c\r"), Err(Error::EmbeddedStartBlock));
}
#[test]
fn the_feature_chooses_the_default_and_nothing_else() {
let missing_carriage_return = b"\x0bMSH|\x1c";
if cfg!(feature = "noncompliance") {
assert_eq!(Tolerance::default(), Tolerance::Lenient);
assert_eq!(decode(missing_carriage_return).unwrap(), b"MSH|");
} else {
assert_eq!(Tolerance::default(), Tolerance::Strict);
assert_eq!(decode(missing_carriage_return), Err(Error::Incomplete));
}
assert_eq!(
decode_with(missing_carriage_return, Tolerance::strict()),
Err(Error::Incomplete)
);
assert_eq!(
decode_with(missing_carriage_return, Tolerance::lenient()).unwrap(),
b"MSH|"
);
}
#[test]
fn knows_what_cannot_be_framed() {
assert!(is_framable(MESSAGE.as_bytes()));
assert!(!is_framable(b"before\x0bafter"));
assert!(!is_framable(b"before\x1cafter"));
}
#[test]
fn tolerance_forgives_a_missing_carriage_return_and_nothing_else() {
let lenient = Tolerance::lenient();
assert_eq!(decode_with(b"\x0bMSH|\x1c", lenient).unwrap(), b"MSH|");
assert_eq!(decode_with(b"\x0bMSH|\x1c\r", lenient).unwrap(), b"MSH|");
assert_eq!(decode_with(b"MSH|\x1c", lenient), Err(Error::NoStartBlock));
assert_eq!(
decode_with(b"\x0bA\x0bB\x1c\r", lenient),
Err(Error::EmbeddedStartBlock)
);
}
#[test]
fn errors_carry_across_the_io_boundary() {
let error = std::io::Error::from(Error::NoStartBlock);
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
assert!(error.to_string().contains("start block"));
}
}