# Specification: the `er7` crate
This is the single source of truth for what the `er7` crate does. It
describes observable behavior, not implementation details; where a rule is
implemented, the module is named so that the two stay in sync.
[`README.md`](../README.md) summarizes this document for newcomers, and
[`er7-format.md`](er7-format.md) describes the ER7 format itself,
independent of this crate — if any of the three disagree, this document
wins for behavior and should be corrected only alongside the code.
Status: describes `er7` 0.1.0 as implemented. Every rule below is pinned by
a unit test next to the code that implements it, or by an integration test
in `tests/integration.rs`. A rule here that is not backed by a test, or a
code change not reflected here, is a bug.
## 1. Scope
Read, query, edit, and write HL7 v2 messages in the **ER7** encoding — the
pipe-hat, positional text encoding described in
[`er7-format.md`](er7-format.md) and defined by chapter 2 of every HL7 v2
release.
This crate is an **encoding**, not a dictionary. It deliberately does not
know:
- which fields a given segment has, or what data type each one carries;
- which message structures exist, or how segments group into them;
- what any code table value means;
- whether a message is valid, complete, or acceptable.
All of that is version-specific and belongs in a layer above; §9 is the one
narrow exception. What this crate guarantees instead is **fidelity**: any
message it reads, it can write back unchanged (§6.2), and any value it
reports is the value that was actually at that position.
The crate has **no dependencies**, and is meant to keep it that way.
## 2. Delimiters (`src/separators.rs`)
### 2.1 The set
A `Separators` holds six characters. Five are structural; the sixth is
informational.
| field | MSH-1 — the character right after the segment name | `\|` | separates fields, and the segment name from field 1 |
| component | MSH-2 position 1 | `^` | separates components within a repetition |
| repetition | MSH-2 position 2 | `~` | separates repetitions within a field |
| escape | MSH-2 position 3 | `\` | opens and closes an escape sequence |
| subcomponent | MSH-2 position 4 | `&` | separates subcomponents within a component |
| truncation | MSH-2 position 5 | `#` | marks a value the sender truncated; optional, HL7 v2.7 and later |
`Separators::default()` is the recommended set with no truncation
character. Nothing in the crate assumes it: every function that needs
delimiters takes them.
### 2.2 Reading them from a message
`Separators::from_header(line)` reads the set from an `MSH`, `FHS`, or
`BHS` line:
- the field separator is the character immediately after the
three-character segment name;
- the encoding characters are the characters from there up to the next
field separator, taken positionally, at most five;
- an encoding character the sender omitted falls back to its recommended
value. A sender that writes `MSH|^~\|` has supplied three encoding
characters, not four: reading stops at the field separator.
### 2.3 Validation
`Separators::validate` rejects a set that cannot encode a message
unambiguously, and `from_header` applies it to every message:
- no delimiter may be alphanumeric — this is also what distinguishes a real
header from a line that merely starts with those three letters;
- no delimiter may be a carriage return or a line feed, which end a
segment;
- no two delimiters may be the same character.
A failing set is `Error::BadHeader`. Nothing else about a message can fail
this way.
## 3. Parsing (`src/parse.rs`)
### 3.1 Lines
- A leading byte-order mark (`U+FEFF`) is removed.
- Input is divided at `\r`, `\n`, or `\r\n`; each division is one segment.
- Lines that are empty or entirely whitespace are dropped.
- **Nothing else is trimmed.** A value that really ended in a space keeps
it, because the crate cannot know whether that space is data.
### 3.2 `parse`
`parse(text)` requires the first surviving line to be an `MSH`, `FHS`, or
`BHS` segment; its delimiters (§2.2) govern the whole message. A different
first segment is `Error::MissingHeader`, and no lines at all is
`Error::Empty`.
Below the header nothing can fail. Unknown segments, local `Z` segments,
ragged field counts, segments with no fields, and stray empty positions are
all data.
### 3.3 `parse_with`
`parse_with(text, separators)` parses a fragment that has no header of its
own — one segment lifted from a log, or a body whose `MSH` was read
separately. It cannot fail: whatever the text is, it becomes a tree.
### 3.4 Splitting
Each segment line splits into fields on the field separator; each field
into repetitions; each repetition into components; each component into
subcomponents. Leaf text is stored exactly as it arrived (§4.1).
Two exceptions:
- **A field the sender left empty has no repetitions at all**, which is
what separates "not sent" from a repetition that is present and blank.
`A~~B` is three repetitions, the middle one blank; `||` is none.
- **Fields 1 and 2 of a header segment are the delimiters themselves** —
MSH-1 *is* the field separator and MSH-2 *is* the encoding characters.
They are stored whole, never split and never escape-decoded, because
doing either would be circular.
## 4. The value tree (`src/message.rs`)
### 4.1 Shape
`Message` → `Segment` → `Field` → `Repetition` → `Component` →
`Subcomponent`. Only `Subcomponent` holds text, in its public `raw` field,
exactly as sent. Decoding happens on demand (§5.2), which is what makes
§6.2 possible.
Every level exposes 1-based accessors matching HL7's own numbering:
`segment.field(5)`, `field.repetition(2)`, `repetition.component(1)`,
`component.subcomponent(2)`, plus `_mut` variants and the shortcuts
`field.component(n)` (first repetition) and `segment.component(f, c)`.
### 4.2 Absent, empty, and null
ER7 distinguishes three states, and so does this crate:
| absent | the field was never sent | the accessor returns `None` |
| empty | `\|\|` — sent with no value | `is_empty()` |
| null | `\|""\|` — sent to clear the value | `is_null()` |
`is_empty` and `is_null` are never both true: the explicit null is text.
The distinction matters, because a receiver updating a record must leave an
absent or empty field alone and must clear a null one.
`Subcomponent::value` reports the explicit null as the empty string, since
that is the value being conveyed; ask `is_null` when the difference
matters.
### 4.3 Editing
`Subcomponent::raw` is public and may be assigned directly, in which case
the caller is responsible for the text containing no unescaped delimiters.
`Subcomponent::set(value, separators)` does that encoding for you (§5.3),
and is the recommended way to write a value.
## 5. Escape sequences (`src/escape.rs`)
### 5.1 The vocabulary
`escapes(text, separators)` tokenizes text into `Escape` values: literal
runs, plus one token per sequence, classified as HL7 defines them —
`\F\ \S\ \T\ \R\ \E\` for the delimiters, `\H\` and `\N\` for highlighting,
`\Xdd..\` for hexadecimal data, `\Zdd..\` for local extensions,
`\Cxxyy\` and `\Mxxyyzz\` for character-set switches, and `\.cmd\` for
formatted-text display commands. A body matching none of these is
`Unknown`; an escape character with no closing partner is `Unterminated`.
Tokenizing never fails, and it is lossless: writing every token back with
`Escape::write_er7` reproduces the input exactly.
### 5.2 Decoding
`unescape(text, separators)` resolves **only the sequences that stand for
characters**: the five delimiter sequences, and `\Xdd..\` when its body is
whole pairs of hexadecimal digits, decoded as bytes and read as UTF-8 with
the usual lossy replacement.
Every other sequence is **kept literally**, escape characters included:
highlighting, formatting commands, character-set switches, local
extensions, unrecognized bodies, and an unterminated escape character.
These say something about presentation or encoding that a plain string
cannot carry, so dropping them would lose more than keeping them.
### 5.3 Encoding
`escape(text, separators)` is the inverse for text containing no sequences
of its own. It encodes the five structural delimiters, and encodes a
carriage return as `\X0D\` and a line feed as `\X0A\` — those would
otherwise end the segment, the one corruption an ER7 writer must never
commit. The truncation character is not encoded: it is structural only
inside MSH-2.
`unescape(escape(value)) == value` for every `value`.
## 6. Writing (`src/render.rs`)
### 6.1 The two forms
Every level of the tree offers a pair:
- `to_er7(separators)` — exactly what a receiver would read, escape
sequences intact;
- `to_text(separators)` — the same, with leaf text escape-decoded per §5.2.
Structural delimiters remain in both, because `SMITH^JOHN` without its
caret is no longer a name and a surname. Fields 1 and 2 of a header are
written literally in both forms (§3.4).
`Message::to_er7()` writes a whole message and `Message::to_er7_with`
takes `RenderOptions`:
- `terminator` — `Cr` (the default, and the only terminator HL7 permits on
the wire), `Lf`, or `CrLf`;
- `trailing_terminator` — whether the last segment gets one too; `false` by
default, because a trailing terminator surprises callers that compare or
concatenate the result, and the transport already marks the end.
`Display for Message` is `to_er7()`.
### 6.2 Round trip
For any message this crate parsed, writing it back reproduces the input
**byte for byte**, except where the input was not already canonical.
Writing is canonical when it fixes the two things §3.1 normalizes: blank
lines are gone, and every terminator is the one `RenderOptions` chose.
Canonical text round-trips exactly.
This holds for messages with unusual delimiters, unknown segments, empty
positions, and escape sequences the crate does not decode.
## 7. Paths and queries (`src/path.rs`, `src/message.rs`)
### 7.1 Notation
A path names one place in a message. The grammar:
```
occurrence = "[" index "]"
index = a number, 1 or greater
```
So `PID`, `PID-5`, `PID-5.1`, `PID-5.1.2`, `OBX[2]-5`, `PID-13[2].1`. Both
`PID-5.1` and `PID.5.1` are accepted, because both are in common use; the
first is what `Display` writes. Surrounding whitespace is ignored.
Every index is 1-based. A `0` is rejected rather than clamped, because it
is almost always an off-by-one: `Error::BadPath`.
### 7.2 What a query returns
`Message::query(path)` returns the first match; `query_all` returns every
match in message order; `query_path` and `query_path_raw` take an
already-parsed `Path`, decoded and as-sent respectively.
- An omitted segment occurrence matches **every** segment of that name, so
`OBX-5` on a result with three `OBX` segments returns three values.
- An omitted repetition matches **every** repetition, unless the path stops
at the field, in which case the whole field is returned with its
repetition separators intact.
- A path that names a level above a subcomponent returns that subtree
written back per §6.1 — `PID-5` on `SMITH^JOHN` gives `SMITH^JOHN`, and
`PID-5.1` gives `SMITH`.
- A position the message does not have contributes nothing; it is not an
error and not an empty string.
- Fields 1 and 2 of a header are returned literally, in either mode.
## 8. Batch and multi-message input (`split_messages`)
`split_messages(text)` cuts input holding several messages, or a whole HL7
batch file, into individual messages. The returned slices borrow from the
input and keep its original terminators, so each can be handed straight to
`parse`.
- A new message begins at each line whose segment name is `MSH`.
- The batch envelope segments `FHS`, `BHS`, `BTS`, and `FTS` are left out:
they describe the file, not a message. The name is matched exactly, so a
local segment such as `BTSX` is not mistaken for a batch trailer.
- If the first surviving line is not an `MSH`, it still starts a message —
which `parse` then rejects on its own. Reporting that is better than
silently dropping it.
## 9. MSH conveniences
`Message` exposes five accessors that read fixed MSH positions:
`message_code` (MSH-9.1), `trigger_event` (MSH-9.2), `message_structure`
(MSH-9.3), `control_id` (MSH-10), and `version` (MSH-12.1). Each returns
`None` when the position is absent or empty.
These are the only HL7 semantics in the crate. They earn the exception
because every tool that touches a message needs them to route or log it,
and because those positions have not moved in any v2 release. Deriving a
structure from the code and trigger event when MSH-9.3 is absent is
**not** done here: that mapping is version-specific and belongs above this
layer.
## 10. Errors
`Error` has four variants, and only two situations produce them:
| `Empty` | the input held no segments |
| `MissingHeader(name)` | the first segment is not `MSH`, `FHS`, or `BHS` |
| `BadHeader(detail)` | the declared delimiters are unusable (§2.3) |
| `BadPath(detail)` | a path could not be read (§7.1) |
Everything else about ER7 is recoverable and is recovered rather than
refused, because a receiver that rejects a message it could have read is
worse than one that reads it as written.
## 11. Limitations
These are scope boundaries, not defects:
- **No dictionary.** See §1. Segment definitions, data types, message
structures, and code tables are out of scope.
- **No validation.** Nothing checks cardinality, length, required fields,
or table membership.
- **No transport.** MLLP framing, TCP, files, and acknowledgement
workflows are out of scope; this crate handles the bytes between the
frames.
- **Formatting escapes are text.** `\.br\`, `\H\`, `\Cxxyy\`, and the rest
are preserved but not interpreted (§5.2). Rendering formatted text is a
presentation concern.
- **Character sets.** Input and output are Rust strings, so UTF-8.
`\Xdd..\` bytes are decoded as UTF-8 with lossy replacement; a message in
some other repertoire declared by MSH-18 or `\Cxxyy\` is not transcoded.
## 12. Command-line behavior (`src/main.rs`)
Documented here because it is a contract, not an implementation detail.
```
er7 [OPTIONS] [FILE]
```
`FILE` holds one or more messages, or a batch file; `-` or no argument
reads standard input. Input is split per §8, and every message is parsed
before anything is written.
| `-q, --query <PATH>` | print the values at an HL7 path, one per line; may be repeated, and the outputs appear in the order the options were given |
| `-n, --normalize` | rewrite the input as canonical ER7 (§6.2), with a trailing terminator on every message |
| `-m, --message <N>` | use only the Nth message of the input, counting from 1 |
| `-r, --raw` | show text as sent, without decoding escape sequences |
| `-t, --terminator <KIND>` | segment terminator to write: `cr` (default), `lf`, `crlf` |
| `-o, --output <FILE>`| write to `FILE` instead of standard output |
| `-h, --help` | print usage |
| `-V, --version` | print the version |
With neither `--query` nor `--normalize`, the command prints an **outline**:
one line per value, labelled with the HL7 path that names it and aligned
into two columns. A path in the outline can be pasted straight back into
`--query`, so repeated segments are labelled `OBX[2]-3.1` and repeated
fields `PID-13[2]`. A level with only one child is not indexed, so a name
sent as a single component reads `NTE-3`, not `NTE-3.1`. Positions with no
value are left out, but an explicit null is shown as the `""` it was sent
as, since that is exactly what distinguishes it. Carriage returns, line
feeds, and tabs inside a decoded value are shown as `\r`, `\n`, and `\t`,
so that one value stays on one line.
When the input holds more than one message, each outline is preceded by a
`# message N` heading naming the message code, trigger event, control ID,
and version where the message supplies them.
Combining `--query` with `--normalize` is an error, since they ask for
different output.
Exit code 0 on success, 1 on any error, with a message on standard error
prefixed `er7: error: `. A query that matches nothing prints nothing and
still succeeds — the message simply did not carry that value.
## 13. References
- [HL7 v2.5 chapter 2, control](https://www.hl7.eu/HL7v2x/v25/std25/ch02.html) — the encoding rules, delimiter table, and escape sequences
- [HL7 v2.8 chapter 2](https://www.hl7.eu/HL7v2x/v28/std28/ch02.html) — the same, with the truncation character
- [HL7 v2+ XML encoding syntax](http://v2plus.hl7.org/2021Jan/xml-encoding-rules.html) — the alternative encoding, and what ER7 is being contrasted with
- [Caristix: HL7 ER7 encoding](https://caristix.com/help-center/v3/test/task/hl7-er7-encoding/)
- [Rhapsody: HL7 escape sequences](https://rhapsody.health/resources/hl7-escape-sequences/)
- [`hl7-2-5-to-xml-using-rust`](https://github.com/joelparkerhenderson/hl7-2-5-to-xml-using-rust) — a sibling crate that adds the v2.5 dictionary this one deliberately omits