hl7probe 0.4.0

Inspect and validate HL7 v2 messages from the command line
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
# hl7probe

**A friendly command-line tool for reading and checking HL7 v2 messages.**

[![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)
[![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
- [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.4.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.

## 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.97.1, `--release`), best of five
runs, 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.67s | 33 MB |
| `hl7probe -f PID-5.1` (one field per message) | 0.14s | 33 MB |
| `hl7probe` (full report) | 2.73s | 34 MB |
| `hl7probe --json` | 4.90s | 34 MB |

That is about 1.3x 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.3 MB, and
5,000 messages (2.6 MB) validate in 0.07s using 5.7 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
```

## 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).