hl7probe 0.10.0

Read and check HL7 v2 messages: a library and a command-line tool
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
# hl7probe

**A friendly command-line tool for reading and checking HL7 v2 messages — and
a Rust library that does the same from your own code.**

[![CI](https://github.com/sudhi001/hl7probe/actions/workflows/ci.yml/badge.svg)](https://github.com/sudhi001/hl7probe/actions/workflows/ci.yml)
[![Release](https://img.shields.io/github/v/release/sudhi001/hl7probe?sort=semver)](https://github.com/sudhi001/hl7probe/releases)
[![crates.io](https://img.shields.io/crates/v/hl7probe?logo=rust)](https://crates.io/crates/hl7probe)
[![docs.rs](https://img.shields.io/docsrs/hl7probe?logo=docsdotrs&label=docs.rs)](https://docs.rs/hl7probe)
[![Downloads](https://img.shields.io/github/downloads/sudhi001/hl7probe/total?label=downloads)](https://github.com/sudhi001/hl7probe/releases)
[![Stars](https://img.shields.io/github/stars/sudhi001/hl7probe?label=stars)](https://github.com/sudhi001/hl7probe/stargazers)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

Hospital systems talk to each other in HL7 v2 — dense lines of text full of
pipes and carets that look like this:

```
PID|1||123456^^^MERCY^MR||Smith^John^A^^Mr||19850312|M
```

Reading that by hand is slow and error-prone. `hl7probe` turns those lines into
something a person can read, and tells you what a receiving hospital system
would reject.

<p align="center">
  <img src="https://raw.githubusercontent.com/sudhi001/hl7probe/main/docs/demo.gif" alt="hl7probe decoding a message, pulling out a field and catching errors in a broken one" width="760">
</p>

---

## Contents

- [Why you'd use it]#why-youd-use-it
- [Install]#install
- [Quick start]#quick-start
- [Reading the output]#reading-the-output
- [Using it as a library]#using-it-as-a-library
- [Interactive viewer]#interactive-viewer
- [Using it in scripts and CI]#using-it-in-scripts-and-ci
- [All the options]#all-the-options
- [What it checks]#what-it-checks
- [What it accepts]#what-it-accepts
- [Performance]#performance
- [Testing]#testing
- [Building from source]#building-from-source
- [Contributing]#contributing
- [License]#license

## Why you'd use it

You are wiring up an interface between two health systems and a message is
being rejected. You need to know **what is in the message** and **what is wrong
with it** — quickly, without opening a heavyweight integration engine.

`hl7probe` answers both in one command:

- Every field is shown with its real name — `PID-5` becomes *Patient Name*.
- Codes are translated — `M` becomes *Male*, `I` becomes *Inpatient*.
- Dates become readable — `19850312` becomes *1985-03-12, age 41*.
- Problems are listed with the exact field, the line number and why it matters.

No configuration, no database, no server. One binary, one file, one answer.

## Install

### Homebrew (macOS and Linux)

```sh
brew install sudhi001/tap/hl7probe
```

Recent Homebrew versions ask you to trust a third-party tap the first time; if
you see that prompt, run `brew trust sudhi001/tap` and install again.

### Download a prebuilt binary

Grab the archive for your platform from the
[releases page](https://github.com/sudhi001/hl7probe/releases), unpack it and
put `hl7probe` somewhere on your `PATH`. The Linux builds are static, so they
run on any distribution regardless of its glibc version:

```sh
shasum -a 256 -c hl7probe-*.tar.gz.sha256      # optional: verify the download
tar xzf hl7probe-*.tar.gz
sudo mv hl7probe-*/hl7probe /usr/local/bin/
```

On Windows, download the `x86_64-pc-windows-msvc.zip` archive, unpack it and put
`hl7probe.exe` in a folder on your `PATH`:

```powershell
Expand-Archive hl7probe-*-x86_64-pc-windows-msvc.zip -DestinationPath .
```

### With Cargo

```sh
cargo install hl7probe
```

Check it works:

```console
$ hl7probe --version
hl7probe 0.10.0
```

None of the commands above pin a version: they each fetch the current release,
which the badges at the top of this page track.

## Quick start

Point it at a message file:

```sh
hl7probe message.hl7
```

Or pipe one in:

```sh
cat message.hl7 | hl7probe
```

Try it on the samples that ship with the project:

```sh
hl7probe examples/adt_a01.hl7     # a healthy admission message
hl7probe examples/invalid.hl7     # one with deliberate mistakes
hl7probe examples/oru_r01.hl7     # a lab result
hl7probe examples/batch.hl7       # a file holding several messages
```

## Reading the output

<p align="center">
  <img src="https://raw.githubusercontent.com/sudhi001/hl7probe/main/docs/report.svg" alt="a decoded message with its validation findings" width="700">
</p>

The report has three parts.

**1. What the message is.** The HL7 version, the message type, a plain-English
description, who sent it and when.

```
HL7 v2.5.1   ADT^A01   Admit / Visit Notification
MSG00001  ·  2024-01-15 14:32:00  ·  HIS/MERCY → LIS/LAB  ·  Production
```

**2. What is inside it.** Each segment is listed with a status mark, then each
field is shown with its name, its raw value, and — after the `›` — the same
value in plain language.

```
Segments
────────────────────────────────────────────
MSH ✓  Message Header
EVN ✓  Event Type
PID ✓  Patient Identification
PV1 ✓  Patient Visit

PID · Patient Identification   line 3
──────────────────────────────────────────────────────────────
    3  Patient Identifier List  123456^^^MERCY^MR   › 123456 (MR, MERCY)
       ~ rep 2                  987654321^^^SSA^SS  › 987654321 (SS, SSA)
    5  Patient Name             Smith^John^A^^Mr    › Mr John A Smith
    7  Date/Time of Birth       19850312            › 1985-03-12, age 41
    8  Administrative Sex       M                   › Male
⚠  11  Patient Address                              (empty)  recommended
```

**3. What is wrong with it.** Five groups of checks, then the individual
findings, each pointing at the field responsible.

```
Validation
────────────────────────────────────────────
✗ Structure         ADT^A01
⚠ Required fields
✗ Data types
⚠ Code tables
✗ Consistency

✗ EVN     required segment is missing  — ADT^A01 requires EVN (Event Type)
✗ PID-7   not a valid date/time  — day 32 does not exist in 1985-03
✗ PV1-3   invalid location  — component 1 (point of care) is empty
⚠ PID-11  missing  — Patient Address should be populated when the value is known

5 errors  ·  8 warnings
```

The three marks mean:

| Mark | Meaning |
| :---: | --- |
|| Fine |
|| Works, but a receiving system may complain — a missing recommended field, an unusual code |
|| Wrong — this will be rejected |

## Using it as a library

The parser and validator are a library as well as a command, so the same
checks can run inside your own code:

```sh
cargo add hl7probe
```

```rust
use hl7probe::{parse, validate, Severity};

let text = std::fs::read_to_string("admit.hl7")?;
let message = parse(&text)?;

println!("{} {}", message.version(), message.type_label());

// PID-5.1 is the patient's family name.
let pid = message.first("PID").expect("the message has a PID");
println!("{}", pid.comp(5, 1));

let report = validate(&message);
for finding in &report.findings {
    if finding.severity == Severity::Error {
        println!("{} {}", finding.location, finding.summary);
    }
}
```

A message borrows the text it was read from rather than copying it, so keep
that string alive for as long as you use the message. For a file holding
several messages, walk them with `parser::split_messages`:

```rust
let (raws, _notes) = hl7probe::parser::split_messages(&text);
for raw in &raws {
    let message = hl7probe::parser::parse_message(raw)?;
    println!("line {}: {}", message.start_line, message.type_label());
}
```

The four public modules are `parser` (decomposition), `validate` (the checks
and their findings), `spec` (the HL7 dictionary the checks read) and
`datetime` (HL7 timestamp handling). Full API documentation is on
[docs.rs](https://docs.rs/hl7probe).

## Interactive viewer

For bigger messages, browse instead of scroll:

```sh
hl7probe --tui message.hl7
```

<p align="center">
  <img src="https://raw.githubusercontent.com/sudhi001/hl7probe/main/docs/tui.svg" alt="the hl7probe interactive viewer" width="820">
</p>

Segments on the left, decoded fields on the right, problems underneath. Move
with the arrow keys, press `?` for help and `q` to quit.

| Key | Action |
| --- | --- |
| `` `` or `j` `k` | Move within the focused panel |
| `` `` or `h` `l` | Jump between segments and fields |
| `tab` | Cycle segments → fields → validation |
| `n` / `p` | Next / previous message in the file |
| `a` | Also show fields that were left empty |
| `v` | Include informational notes |
| `r` | Show the raw segment line |
| `f` | Findings for this segment only, or the whole message |
| `?` | Help |
| `q` or `esc` | Quit |

## Using it in scripts and CI

**One line per message**, ideal for checking a folder full of test messages:

```sh
$ hl7probe -q outbound/*.hl7
adt_a01.hl7  ADT^A01  ✓ message passes all checks
batch.hl7#1  ADT^A01  ✓ message passes all checks
batch.hl7#2  ADT^A03  ✓ message passes all checks
invalid.hl7  ADT^A01  5 errors  ·  8 warnings
```

**Exit codes** make it a gate in a build pipeline:

| Code | Meaning |
| :---: | --- |
| `0` | No errors (warnings are allowed unless you pass `--strict`) |
| `1` | At least one validation error |
| `2` | The input could not be read, or contained no HL7 message |

```sh
hl7probe --strict outbound/*.hl7 || exit 1
```

**Pull out a single value** without writing a parser:

```sh
$ hl7probe -f PID-5.1 message.hl7        # family name
Smith
$ hl7probe -f PID-3 message.hl7          # every patient identifier
123456^^^MERCY^MR
987654321^^^SSA^SS
$ hl7probe -f 'OBX[2]-5' results.hl7     # value of the second OBX segment
39.1
```

HL7 escape sequences are already decoded, so the output drops straight into a
shell script.

**Machine-readable reports** for dashboards and tests:

```sh
hl7probe --json message.hl7 | jq '.files[].messages[].findings[] | select(.severity == "error")'
```

## All the options

```
hl7probe [OPTIONS] [FILE]...
```

`FILE` can be given more than once. Use `-`, or no file at all, to read from
standard input.

| Option | What it does |
| --- | --- |
| `-t`, `--tui` | Open the interactive viewer |
| `--json` | Print the whole report as JSON |
| `-q`, `--quiet` | Print one verdict line per message |
| `-v`, `--verbose` | Include informational notes |
| `-a`, `--all` | Show fields that were left empty |
| `-s`, `--segment PID,PV1` | Only show these segments in detail |
| `--summary` | Segment list and verdict only, no field tables |
| `--raw` | Print the original segment line above each table |
| `-f`, `--field PID-5.1` | Print one value and nothing else |
| `-m`, `--message N` | Only inspect the Nth message in the file |
| `--strict` | Count warnings as failures in the exit code |
| `--color auto\|always\|never` | Colour control (`NO_COLOR` is respected) |
| `--width N` | Wrap at N columns instead of the terminal width |
| `-h`, `--help` | Full help |

## What it checks

**Structure** — the message type in `MSH-9` is matched against the official
message layout (ADT, ORU, ORM/OML, ACK, SIU, MDM, VXU, DFT, BAR, RDE, QRY and
others). A missing required segment is an error; an unexpected or out-of-order
segment is a warning. Site-specific `Z` segments are left alone.

**Required fields** — fields the standard marks as required are errors when
absent. Fields that should be filled in whenever the value is known — patient
address, visit number, observation time — are warnings.

**Data types** — dates and times are checked against the real calendar, so
`19850332` and `20230229` are caught, along with bad timezone offsets,
non-numeric numbers, identifiers with no ID, and locations with no ward.

**Code tables** — coded values are looked up in their HL7 table, so `Q` in the
patient class field is flagged. Unknown codes are warnings, because local code
sets are normal; tables that allow no local values — processing ID, version,
acknowledgement code, yes/no — are errors.

**Consistency** — the cross-field rules that catch real interface bugs:

- the event code in `EVN-1` disagreeing with the trigger in `MSH-9`
- a discharge time earlier than the admission time
- a date of birth in the future, or an implausible age
- a discharge message with no discharge time
- an inpatient with no assigned location
- an observation value that contradicts its declared type (`NM` holding text)
- set IDs on repeating segments that do not count up
- the same patient identifier repeated twice
- accented characters sent with no character set declared in `MSH-18`
- a message control ID too long for the receiving system

Every finding carries a severity, the exact field, the line number in the file
and an explanation of why it matters.

### How much of HL7 the dictionary covers

126 segments carry field-by-field definitions — every field HL7 2.5.1 gives
them, 1,928 in total — and 165 code tables hold 2,461 values. The other five
versions are carried on top of that baseline as 1,659 recorded differences.

HL7 2.5.1 publishes 149 segments; the 23 that are not here are the query,
equipment and laboratory-automation segments (`EQU`, `ECD`, `SAC`, `TCD` and
the like), and a message carrying one is reported as carrying a segment this
build does not know. Four segments from other versions — `ARV`, `OBS`, `ORO`,
`UAC` — are recognised by name, with no field definitions behind them.

The definitions are parsed from the attribute tables the standard publishes and
the code values from HL7's own terminology, rather than transcribed by hand,
and a test fails if a field — in the baseline or in any version's differences —
ever names a table the dictionary cannot supply. A further 121 tables are
recorded as deliberately unchecked, each with the reason: most are the
site-defined tables HL7 publishes no values for, and the rest are numbers HL7
has since reused for something else, where checking an older message against
the current list would report every value in it as wrong.

### What is not checked

Free-text fields (`ST`, `TX`, `FT`) have nothing to check beyond their
presence. Coded fields (`ID`, `IS`) are checked against the HL7 table they name
and not otherwise, since without a table any value is legal. `OBX-5` carries
whatever type `OBX-2` declares and is checked against that.

Beyond those, several composite types are read and displayed but their
components are not individually validated: addresses (`XAD`), phone numbers
(`XTN`), organisation names (`XON`), and the timing, price and quantity types
(`TQ`, `CP`, `CQ`). A malformed phone number will be shown, not reported.

### Which HL7 version the checks come from

A message is read and checked against the version it declares in `MSH-12`. Field
numbering, names, usage and code tables all move between versions of HL7 v2, so
the same message can be correct under one and wrong under another: `PID-39`
arrived in 2.5, and sending it in a message that declares 2.4 is a real error.
The version decides what a field is called, too — `PID-3` is one internal
patient ID in 2.3 and a list of identifiers in 2.5.1, and the report says
whichever the message asked for.

Definitions are carried for **2.3, 2.3.1, 2.4, 2.5, 2.5.1 and 2.6**. Messages
declaring any other version are still read and checked, against 2.5.1, and the
report says so rather than leaving it implicit:

```
ℹ MSH-12 checked against HL7 2.5.1  — this build carries no field definitions
  for 2.7, so the 2.5.1 ones were used; field numbering, usage and code tables
  differ between versions, and some findings may not apply
```

Four segments in 2.3 — `OM1`, `OM3`, `OM5` and `ORC` — fall back to the 2.5.1
definitions, because the rows of their attribute tables in the published 2.3
document do not line up with their columns and reading them wrong would be
worse than not reading them. HL7 2.2 and earlier are not carried at all: the
published 2.2 tables drop the blank cells out of the usage, repeat and table
columns, so which field each value belongs to cannot be recovered.

`spec::Dictionary::for_version` exposes this to callers, and
`spec::DICTIONARY_VERSION` names the baseline the others are recorded against.

## What it accepts

Real-world message files are messy. `hl7probe` copes with:

- Windows, Unix or classic Mac line endings (`CRLF`, `LF`, `CR`)
- MLLP framing bytes left over from a network capture
- Batch files with `FHS` / `BHS` / `BTS` / `FTS` wrappers
- Several messages in one file, each reported separately
- Non-UTF-8 (latin-1) text, decoded instead of rejected
- Custom delimiters — whatever `MSH-1` and `MSH-2` declare is what is used

## Performance

A batch file is read into memory once and never copied. Messages are parsed,
written and dropped one at a time, and a parsed message is a set of views over
the file text rather than an owned tree, so peak memory is close to the size of
the input regardless of which output mode you ask for.

Measured on an Apple M4 (macOS 26.6, rustc 1.98.0, `--release`), best of seven
runs with output discarded, against a file of 50,000 copies of
`examples/adt_a01.hl7` — 26 MB, or about 300,000 segments:

| Command | Time | Peak memory |
| --- | --- | --- |
| `hl7probe -q` (validate only) | 0.77s | 32 MB |
| `hl7probe -f PID-5.1` (one field per message) | 0.02s | 31 MB |
| `hl7probe` (full report) | 2.96s | 32 MB |
| `hl7probe --json` | 4.49s | 32 MB |

`-f` is the fastest because it is the only mode that never validates: it finds
one field per message and writes it.

That is about 1.2x the file, and most of it is the file. At the sizes most
runs actually are, none of this matters: a single message reports in about
4 ms end to end — most of that being process startup — using 2.7 MB, and
5,000 messages (2.6 MB) validate in 0.08s using 5.8 MB.

Memory used to scale with the batch rather than the message, because every
message was parsed up front and every field, component and subcomponent became
its own `String`:

| Command | Peak memory before | After |
| --- | --- | --- |
| `hl7probe -q` | 2,234 MB | 33 MB |
| `hl7probe -f PID-5.1` | 2,234 MB | 33 MB |
| `hl7probe` | 2,417 MB | 34 MB |
| `hl7probe --json` | 4,598 MB | 34 MB |

A parsed message costs 2.4 KB and 35 allocations, down from 40 KB and 482. That
is what the interactive viewer pays per message, since it has to hold the whole
batch to let you page back and forth. Validating one costs a further 19
allocations when it is clean, and beyond that only what its findings need.

To reproduce:

```sh
python3 -c "open('bulk.hl7','wb').write(open('examples/adt_a01.hl7','rb').read()*50000)"
cargo build --release
/usr/bin/time -l ./target/release/hl7probe -q bulk.hl7   # macOS; use -v on Linux
```

## Testing

169 tests: 136 unit tests beside the code they cover, 29 that run the built
binary the way you would, 3 that hold the parser to its properties over a large
body of damaged input, and a doctest that compiles and runs the example in the
library documentation, so it cannot drift from the API.

The property tests are the ones that earn their keep on real traffic. Every
truncation of every example message, plus thousands of randomly corrupted
copies, are put through the parser to establish that nothing can make it panic,
and that each segment it decodes still rejoins to the exact line it came from —
a decoder that quietly loses a character is worse than one that refuses the
message. The corpus is generated from a fixed seed, so a failure is
reproducible rather than a matter of luck.

```sh
cargo test --all-targets     # unit and integration
cargo test --doc             # the documented example
```

Line coverage is **95%**, enforced in CI by a floor that fails the build if it
drops. To reproduce:

```sh
cargo install cargo-llvm-cov
rustup component add llvm-tools-preview
cargo llvm-cov --all-targets --summary-only -- --include-ignored
```

What the remaining 5% is, and why it stays there:

| Uncovered | Lines | Why |
| --- | --- | --- |
| Terminal driving in `--tui` | 57 | `run`, `setup`, `restore` and the event loop need a real terminal in raw mode. Covering them means a pty harness, which buys flakiness rather than confidence — the drawing they call is tested against a fake backend. |
| Everything else | ~158 | Error arms for conditions the tests cannot force (a closed stdout mid-write, stdin attached to a terminal), and `const fn`s evaluated at compile time, which no runtime test can reach. |

The interactive viewer's rendering *is* tested: `ratatui`'s `TestBackend` draws
each panel into an in-memory buffer that the tests read back, so what appears on
screen is checked without needing a screen.

## Building from source

Requires [Rust](https://rustup.rs) 1.88 or newer.

```sh
git clone https://github.com/sudhi001/hl7probe.git
cd hl7probe
cargo build --release      # binary at target/release/hl7probe
cargo test                 # 77 tests
cargo clippy --all-targets
```

The code is organised as:

| File | Responsibility |
| --- | --- |
| `src/parser.rs` | Splitting messages into segments, fields, components |
| `src/spec.rs` | The HL7 dictionary: field names, code tables, message layouts |
| `src/validate.rs` | The checks, one rule per concern |
| `src/view.rs` | The decoded field model both output modes share |
| `src/render.rs` | The printed report |
| `src/tui.rs` | The interactive viewer |
| `src/datetime.rs` | HL7 date and time handling |
| `src/text.rs` | Padding and truncation helpers |
| `src/main.rs` | Command-line interface |

Adding a validation check means writing one `Rule` implementation in
`src/validate.rs` and listing it in `RULES`; nothing else changes.

## Changelog

Release notes live in [CHANGELOG.md](CHANGELOG.md).

## Contributing

Issues and pull requests are welcome. Please make sure `cargo test` and
`cargo clippy --all-targets` pass, and add a test alongside any behaviour
change — the fastest way to describe an HL7 bug is a message that reproduces it.

Note your change under `Unreleased` in [CHANGELOG.md](CHANGELOG.md).

## License

MIT — see [LICENSE](LICENSE).