Skip to main content

haste_hl7v2/
ack.rs

1//! HL7 v2 acknowledgement messages.
2//!
3//! A sender waits for an `ACK` message, not for the single `<ACK>` control
4//! byte: the byte says "the bytes arrived", while the message says "the
5//! application accepted it, and here is the control id it accepted". An
6//! interface engine that receives a bare control character generally treats
7//! the exchange as failed and retries, so a listener that answers with one
8//! looks healthy while every message is resent.
9//!
10//! The reply echoes the sender's `MSH-10` in `MSA-2`, which is how the sender
11//! matches an acknowledgement to the message it sent.
12
13use crate::parser::ParsedHL7V2Message;
14
15/// `MSA-1`, the acknowledgement code.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum AckCode {
18    /// Accepted.
19    ApplicationAccept,
20    /// Rejected for a reason the sender could fix (a malformed message).
21    ApplicationReject,
22    /// Failed for a reason the sender cannot fix (storage was unavailable).
23    /// A sender is expected to retry these.
24    ApplicationError,
25}
26
27impl AckCode {
28    #[must_use]
29    pub const fn as_str(self) -> &'static str {
30        match self {
31            AckCode::ApplicationAccept => "AA",
32            AckCode::ApplicationReject => "AR",
33            AckCode::ApplicationError => "AE",
34        }
35    }
36}
37
38/// What the listener needs from an inbound message to answer it.
39///
40/// Read with a tolerant scan rather than the full parser: a message can be
41/// malformed enough to reject yet still carry a usable control id, and a
42/// rejection the sender cannot match to its message is nearly useless.
43#[derive(Debug, Clone, Default)]
44pub struct InboundHeader {
45    /// `MSH-3`, the sending application. Becomes the receiving application of
46    /// the reply.
47    pub sending_application: String,
48    /// `MSH-4`, the sending facility.
49    pub sending_facility: String,
50    /// `MSH-5`, the receiving application. Becomes the sending application of
51    /// the reply.
52    pub receiving_application: String,
53    /// `MSH-6`, the receiving facility.
54    pub receiving_facility: String,
55    /// `MSH-10`, the message control id. Echoed in `MSA-2`.
56    pub control_id: String,
57    /// `MSH-11`, the processing id (`P`, `T`, `D`).
58    pub processing_id: String,
59    /// `MSH-12`, the version. The reply is sent in the sender's version.
60    pub version: String,
61}
62
63/// Fields of a raw `MSH` segment, by HL7 number.
64///
65/// `MSH-1` is the separator itself, so `field(2)` is the first value after it.
66struct MshFields<'a> {
67    fields: Vec<&'a str>,
68}
69
70impl<'a> MshFields<'a> {
71    /// Splits the `MSH` line of `message`, or `None` when there isn't one.
72    fn parse(message: &'a str) -> Option<Self> {
73        let line = message
74            .split(['\r', '\n'])
75            .find(|line| line.starts_with("MSH"))?;
76
77        // The character after `MSH` is the field separator, by definition.
78        let separator = line.chars().nth(3)?;
79
80        Some(MshFields {
81            fields: line.split(separator).collect(),
82        })
83    }
84
85    /// The value of `MSH-<number>`, empty when absent.
86    ///
87    /// The split yields `["MSH", <encoding chars>, ...]`, so `MSH-2` is at
88    /// index 1 and field `N` is at `N - 1`.
89    fn field(&self, number: usize) -> &str {
90        number
91            .checked_sub(1)
92            .and_then(|index| self.fields.get(index))
93            .copied()
94            .unwrap_or_default()
95    }
96}
97
98impl InboundHeader {
99    /// Reads what can be read from a possibly malformed message.
100    #[must_use]
101    pub fn scan(message: &str) -> Self {
102        let Some(msh) = MshFields::parse(message) else {
103            return Self::default();
104        };
105
106        Self {
107            sending_application: msh.field(3).to_string(),
108            sending_facility: msh.field(4).to_string(),
109            receiving_application: msh.field(5).to_string(),
110            receiving_facility: msh.field(6).to_string(),
111            control_id: msh.field(10).to_string(),
112            processing_id: msh.field(11).to_string(),
113            version: msh.field(12).to_string(),
114        }
115    }
116
117    /// Reads the header from an already-parsed message.
118    #[must_use]
119    pub fn from_parsed(message: &ParsedHL7V2Message) -> Self {
120        let serialized: String = crate::serialize::SerializeMessage(&message.0).into();
121        Self::scan(&serialized)
122    }
123}
124
125/// The HL7 version used when the sender did not state one.
126const DEFAULT_VERSION: &str = "2.5.1";
127
128/// Builds an `ACK` message replying to `header`.
129///
130/// Addressing is reversed -- the sender's application and facility become the
131/// receiver's -- so the reply routes back the way the message came. `text` is
132/// placed in `MSA-3` and is what an operator reads when a message is rejected,
133/// so it should say what was wrong rather than that something was.
134#[must_use]
135pub fn build_ack(
136    header: &InboundHeader,
137    code: AckCode,
138    text: Option<&str>,
139    timestamp: &str,
140) -> String {
141    let version = if header.version.is_empty() {
142        DEFAULT_VERSION
143    } else {
144        header.version.as_str()
145    };
146
147    // A reply needs its own control id. Deriving it from the inbound one keeps
148    // it unique per message without needing a counter or a clock.
149    let ack_control_id = if header.control_id.is_empty() {
150        format!("ACK{timestamp}")
151    } else {
152        format!("ACK{}", header.control_id)
153    };
154
155    let processing_id = if header.processing_id.is_empty() {
156        "P"
157    } else {
158        header.processing_id.as_str()
159    };
160
161    let msh = format!(
162        "MSH|^~\\&|{sending_app}|{sending_fac}|{receiving_app}|{receiving_fac}|{timestamp}||ACK|{ack_control_id}|{processing_id}|{version}",
163        // Reversed: we are replying to whoever sent to us.
164        sending_app = escape(&header.receiving_application),
165        sending_fac = escape(&header.receiving_facility),
166        receiving_app = escape(&header.sending_application),
167        receiving_fac = escape(&header.sending_facility),
168    );
169
170    let msa = match text {
171        Some(text) if !text.is_empty() => format!(
172            "MSA|{}|{}|{}",
173            code.as_str(),
174            escape(&header.control_id),
175            escape(text)
176        ),
177        _ => format!("MSA|{}|{}", code.as_str(), escape(&header.control_id)),
178    };
179
180    // Segments are separated by a carriage return, and the message ends with
181    // one; a trailing separator is what most receivers expect.
182    format!("{msh}\r{msa}\r")
183}
184
185/// Replaces characters that would be read as delimiters.
186///
187/// A rejection reason is free text and an operator writes whatever explains the
188/// failure; an unescaped `|` in it would silently shift every field after it.
189fn escape(value: &str) -> String {
190    value
191        .replace('\\', "\\E\\")
192        .replace('|', "\\F\\")
193        .replace('^', "\\S\\")
194        .replace('&', "\\T\\")
195        .replace('~', "\\R\\")
196        .replace(['\r', '\n'], " ")
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    const MESSAGE: &str = "MSH|^~\\&|SENDAPP|SENDFAC|RECVAPP|RECVFAC|20260101120000||ADT^A01|MSG00001|P|2.5.1\rPID|1||42||DOE^JOHN\r";
204
205    /// The named segment of a message. Segments are `\r`-separated, which
206    /// `str::lines` does not split on.
207    fn segment<'a>(message: &'a str, id: &str) -> &'a str {
208        message
209            .split(['\r', '\n'])
210            .find(|line| line.starts_with(id))
211            .unwrap_or_else(|| panic!("{id} segment in {message:?}"))
212    }
213
214    #[test]
215    fn a_header_is_read_by_hl7_field_number() {
216        let header = InboundHeader::scan(MESSAGE);
217
218        assert_eq!(header.sending_application, "SENDAPP");
219        assert_eq!(header.sending_facility, "SENDFAC");
220        assert_eq!(header.receiving_application, "RECVAPP");
221        assert_eq!(header.receiving_facility, "RECVFAC");
222        assert_eq!(header.control_id, "MSG00001");
223        assert_eq!(header.processing_id, "P");
224        assert_eq!(header.version, "2.5.1");
225    }
226
227    /// The sender matches the reply to its message by the echoed control id,
228    /// so this is the field that matters most in an acknowledgement.
229    #[test]
230    fn an_ack_echoes_the_control_id() {
231        let header = InboundHeader::scan(MESSAGE);
232        let ack = build_ack(&header, AckCode::ApplicationAccept, None, "20260101120001");
233
234        assert!(ack.contains("MSA|AA|MSG00001"), "{ack}");
235    }
236
237    /// Addressing is reversed so the reply routes back to the sender.
238    #[test]
239    fn an_ack_reverses_the_addressing() {
240        let header = InboundHeader::scan(MESSAGE);
241        let ack = build_ack(&header, AckCode::ApplicationAccept, None, "20260101120001");
242
243        let fields: Vec<&str> = segment(&ack, "MSH").split('|').collect();
244        // MSH-3/4 of the reply are the inbound MSH-5/6.
245        assert_eq!(fields[2], "RECVAPP");
246        assert_eq!(fields[3], "RECVFAC");
247        assert_eq!(fields[4], "SENDAPP");
248        assert_eq!(fields[5], "SENDFAC");
249    }
250
251    #[test]
252    fn an_ack_is_sent_in_the_senders_version() {
253        let header = InboundHeader::scan(MESSAGE);
254        let ack = build_ack(&header, AckCode::ApplicationAccept, None, "t");
255        assert!(ack.contains("|2.5.1"), "{ack}");
256
257        // A sender that states no version gets a default rather than an empty
258        // field, which some receivers reject outright.
259        let mut header = header;
260        header.version = String::new();
261        let ack = build_ack(&header, AckCode::ApplicationAccept, None, "t");
262        assert!(ack.contains(DEFAULT_VERSION), "{ack}");
263    }
264
265    #[test]
266    fn a_rejection_carries_its_reason() {
267        let header = InboundHeader::scan(MESSAGE);
268        let ack = build_ack(
269            &header,
270            AckCode::ApplicationReject,
271            Some("PID-3 is required"),
272            "t",
273        );
274
275        assert!(ack.contains("MSA|AR|MSG00001|PID-3 is required"), "{ack}");
276    }
277
278    /// A reason is free text; an unescaped delimiter in it would shift every
279    /// field after it.
280    #[test]
281    fn a_reason_containing_a_delimiter_is_escaped() {
282        let header = InboundHeader::scan(MESSAGE);
283        let ack = build_ack(
284            &header,
285            AckCode::ApplicationError,
286            Some("bad field|value^here"),
287            "t",
288        );
289
290        // Segments are separated by `\r`, which `str::lines` does not split on.
291        let msa = segment(&ack, "MSA");
292        assert_eq!(
293            msa.split('|').count(),
294            4,
295            "delimiters must not add fields: {msa}"
296        );
297        assert!(msa.contains("\\F\\"), "{msa}");
298        assert!(msa.contains("\\S\\"), "{msa}");
299    }
300
301    /// A message malformed enough to reject may still carry a control id, and
302    /// a rejection the sender cannot match to its message is nearly useless.
303    #[test]
304    fn a_control_id_is_recovered_from_a_malformed_message() {
305        let malformed = "MSH|^~\\&|SENDAPP|SENDFAC|RECVAPP|RECVFAC|20260101||ADT^A01|MSG42|P|2.5.1\rPID|garbage";
306        let header = InboundHeader::scan(malformed);
307
308        assert_eq!(header.control_id, "MSG42");
309
310        let ack = build_ack(&header, AckCode::ApplicationReject, Some("no PID-3"), "t");
311        assert!(ack.contains("MSA|AR|MSG42"), "{ack}");
312    }
313
314    /// A message with no MSH at all still gets an answer, since the sender is
315    /// waiting for one.
316    #[test]
317    fn a_message_without_an_msh_still_produces_an_ack() {
318        let header = InboundHeader::scan("this is not HL7");
319        let ack = build_ack(
320            &header,
321            AckCode::ApplicationReject,
322            Some("no MSH"),
323            "20260101",
324        );
325
326        assert!(ack.starts_with("MSH|^~\\&|"), "{ack}");
327        assert!(ack.contains("MSA|AR"), "{ack}");
328        // With no inbound control id, the reply still carries its own.
329        assert!(ack.contains("ACK20260101"), "{ack}");
330    }
331
332    /// The reply must parse as HL7 v2, or the sender cannot read it.
333    #[test]
334    fn an_ack_round_trips_through_the_parser() {
335        let header = InboundHeader::scan(MESSAGE);
336        let ack = build_ack(&header, AckCode::ApplicationAccept, None, "20260101120001");
337
338        let parsed = ParsedHL7V2Message::try_from(ack.as_str()).expect("ACK parses");
339        let segments = parsed.0.segments.expect("segments");
340        assert_eq!(segments.len(), 2);
341        assert_eq!(segments[0].id.value.as_deref(), Some("MSH"));
342        assert_eq!(segments[1].id.value.as_deref(), Some("MSA"));
343    }
344}