1use crate::parser::ParsedHL7V2Message;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum AckCode {
18 ApplicationAccept,
20 ApplicationReject,
22 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#[derive(Debug, Clone, Default)]
44pub struct InboundHeader {
45 pub sending_application: String,
48 pub sending_facility: String,
50 pub receiving_application: String,
53 pub receiving_facility: String,
55 pub control_id: String,
57 pub processing_id: String,
59 pub version: String,
61}
62
63struct MshFields<'a> {
67 fields: Vec<&'a str>,
68}
69
70impl<'a> MshFields<'a> {
71 fn parse(message: &'a str) -> Option<Self> {
73 let line = message
74 .split(['\r', '\n'])
75 .find(|line| line.starts_with("MSH"))?;
76
77 let separator = line.chars().nth(3)?;
79
80 Some(MshFields {
81 fields: line.split(separator).collect(),
82 })
83 }
84
85 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 #[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 #[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
125const DEFAULT_VERSION: &str = "2.5.1";
127
128#[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 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 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 format!("{msh}\r{msa}\r")
183}
184
185fn 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 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 #[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 #[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 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 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 #[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 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 #[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 #[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 assert!(ack.contains("ACK20260101"), "{ack}");
330 }
331
332 #[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}