hl7probe 0.5.0

Inspect and validate HL7 v2 messages from the command line
Documentation

hl7probe

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

CI Release crates.io Downloads Stars License: MIT

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.


Contents

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)

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, 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:

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:

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

With Cargo

cargo install hl7probe

Check it works:

$ hl7probe --version
hl7probe 0.5.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:

hl7probe message.hl7

Or pipe one in:

cat message.hl7 | hl7probe

Try it on the samples that ship with the project:

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

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:

cargo add hl7probe
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:

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.

Interactive viewer

For bigger messages, browse instead of scroll:

hl7probe --tui message.hl7

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:

$ 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
hl7probe --strict outbound/*.hl7 || exit 1

Pull out a single value without writing a parser:

$ 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:

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 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

The field definitions, usages and code tables are taken from HL7 2.5.1. Messages declaring any version are read and checked, but field numbering, names, whether a field is required and which table it names all change between versions of HL7 v2. When a message declares a different version the report says so against MSH-12, so version-sensitive findings can be weighed accordingly:

ℹ MSH-12 checked against HL7 2.5.1  — this message declares 2.3; field
  numbering, names, usage and code tables differ between versions, so
  field-level findings may not all apply

Per-version dictionaries are not implemented. spec::DICTIONARY_VERSION names the version in use, for callers that need to reason about it.

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:

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 1.88 or newer.

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.

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.

License

MIT — see LICENSE.