snomed-cli 0.3.0

Command-line toolkit for SNOMED CT RF2: SCTID validation, release loading, concept lookup, ECL queries, OWL classification and necessary normal form
Documentation
# snomed-cli

A command-line binary over the `snomed` workspace: validate an SCTID,
sanity-check an RF2 release directory, look up a concept's neighborhood,
run an ECL query, classify a release's OWL axioms, or compute their
necessary normal form — all from the terminal, without writing Rust.

## Install / run

From the workspace root:

```sh
cargo run -p snomed-cli -- <subcommand> [args...]
```

Or build the binary once and use it directly:

```sh
cargo build -p snomed-cli --release
./target/release/snomed-cli <subcommand> [args...]
```

## Subcommands

```
snomed-cli sctid <id>                       validate an SCTID and show its structure
snomed-cli load <release-dir> [--full]      load a release directory, print a summary
snomed-cli lookup <release-dir> <id>        look up a concept: FSN, synonyms, parents, children
snomed-cli ecl <release-dir> <expression>   evaluate an ECL expression (quote it)
snomed-cli export <rf2-file> [output-file]  convert one RF2 file to NDJSON (stdout if no output file)
snomed-cli export <release-dir> <output-dir> [--full]  convert every exportable file in a release directory to NDJSON
snomed-cli validate <release-dir> [--full]  check referential integrity and IS-A acyclicity
snomed-cli classify <release-dir> [concept-id] [--full]  classify the release's OWL axioms
snomed-cli nnf <release-dir> [concept-id] [--full]  necessary normal form: proximal parents + reduced attributes
```

`<release-dir>` is an unzipped RF2 release directory (i.e. the folder
containing `Terminology/` and `Refset/`, or the top of a release with
`Snapshot/`/`Full`/`Delta` subfolders — the loader walks recursively, so
either works). `load`/`lookup`/`ecl` read the **Snapshot** view by default;
`load --full` reads the **Full** view instead.

### `sctid`

```sh
$ snomed-cli sctid 22298006
22298006
  component type: Concept
  format:         short (International)
  partition:      00
  item id:        22298
  check digit:    6
```

No release directory needed — pure SCTID structural validation.

### `load`

```sh
$ snomed-cli load ./SnomedCT_InternationalRF2_PRODUCTION_20250801T120000Z/Snapshot
loaded <N> file(s), skipped <M> in <elapsed>
  skipped <path>: content type `cRefset` (summary `OrderedComponent`) is not yet loaded into SnapshotStore
  ...
concepts: <count> (<active count> active)
```

(Illustrative shape — actual counts depend on the release loaded; see
`crates/snomed-store/examples/benchmark_synthetic_release.rs` and `plan.md`
Phase 4 for real timing numbers at International-Edition scale.)

Loads the directory through the same path a real consumer would use, and
reports what got skipped and why — a quick way to sanity-check that a
release directory is laid out as expected before writing code against it.
This is "did it load cleanly", not deep semantic validation — for dangling
references and cyclic hierarchy, see `validate` below.

### `lookup`

```sh
$ snomed-cli lookup ./Snapshot 22298006
22298006  active=true  module=900000000000207008
  FSN: Myocardial infarction (disorder)
  synonym: Heart attack
  parents:
    <sctid>  <FSN of each direct parent>
  children:
    <sctid>  <FSN of each direct subtype>
```

(`parents`/`children` sections are only printed when non-empty; exact
contents depend on the loaded release.)

### `ecl`

```sh
$ snomed-cli ecl ./Snapshot "<< 404684003 MINUS << 64572001"
<N> match(es)
404684003  Clinical finding (finding)
...
```

Pass the expression as a single (shell-quoted) argument.

### `export`

```sh
$ snomed-cli export sct2_Concept_Snapshot_INT_20250801.txt
{"id":"138875005","effectiveTime":"20190731","active":true,"moduleId":"900000000000207008","definitionStatusId":"900000000000074008"}
{"id":"404684003","effectiveTime":"20190731","active":true,"moduleId":"900000000000207008","definitionStatusId":"900000000000074008"}
...

$ snomed-cli export sct2_Concept_Snapshot_INT_20250801.txt concepts.ndjson
wrote 361763 line(s) to concepts.ndjson
```

(The two JSON lines above are real output, verified by running `export`
against a tiny hand-written two-row Concept file; the file name and the
`wrote N line(s)` count in these examples are illustrative.)

Single-file mode auto-detects the record type from the file name the same
way `load` does internally. Every RF2 record type this workspace can
parse is exportable — the three core component types,
`RelationshipConcreteValue`, and all 18 refset types (spec/08), including
MRCM and Ordered/Annotation. Any content type this crate doesn't
recognize at all is still skipped and reported by name (same as `load`),
never a hard error. SCTIDs, UUIDs, and `effectiveTime` are always
rendered as JSON **strings**, never numbers — SCTIDs can reach 18 digits,
well past where JSON numbers keep exact precision in common consumers
(JavaScript's `JSON.parse`, `jq` in some modes). Only genuinely small
bounded integers (`relationshipGroup`, `mapGroup`, `mapPriority`,
`attributeOrder`, `descriptionLength`) come through as JSON numbers.

`export` also has a **whole-release-directory** mode, auto-detected when
the first argument is a directory rather than a file:

```sh
$ snomed-cli export ./Snapshot ./ndjson-out
exported 2 file(s), skipped 0 to ./ndjson-out
$ ls ./ndjson-out
sct2_Concept_Snapshot_INT_20190731.ndjson
sct2_Relationship_Snapshot_INT_20190731.ndjson
```

(Real output, verified against a tiny hand-written two-file Snapshot
release.) Every exportable file under `<release-dir>` is converted and
written as `<file-stem>.ndjson` flattened into `<output-dir>` (release file
names are unique within one release view, so no collisions); `--full`
switches to the Full view, same as `load`/`validate`. Content types with no
exporter yet are skipped and reported by name, exactly like `load`'s
`LoadReport`; malformed data inside a recognized file is a hard error, also
like `load`. This mode is a thin wrapper around `snomed_store::
list_release_files` plus the same per-file dispatch single-file mode uses —
the directory-walking and release-view-filtering logic itself lives in
`snomed-store`, not duplicated here (see `AGENTS/cli-engineer.md`).

### `validate`

```sh
$ snomed-cli validate ./Snapshot
loaded <N> file(s), skipped <M> in <elapsed>
no issues found (<count> concepts checked)
```

Or, when it finds something:

```sh
$ snomed-cli validate ./Snapshot
loaded 2 file(s), skipped 0 in 422.88µs
1 issue(s) found:
  dangling relationship source references (1):
    2002021
```

(Both blocks above are real output, verified against tiny hand-written
Concept/Relationship files — one clean, one with a relationship whose
`sourceId` doesn't resolve to a loaded concept; only the file count/elapsed
time in the first block are illustrative.) Checks referential integrity
(every description's `conceptId`, every relationship's `sourceId`/
`destinationId`, resolve to a loaded concept) and IS-A acyclicity (no
concept sits on a cycle in the active inferred `116680003 |is a|` graph —
spec/07 rule 3). Findings are grouped by category, each listing the ids of
the offending components. Refset `referencedComponentId` dangling checks
are out of scope for now — see `crates/snomed-store/README.md`.

### `classify`

```sh
$ snomed-cli classify ./Snapshot 22298006
loaded 3 file(s), skipped 0 in 1.90ms
OWL axioms: 2 parsed, 0 failed to parse
22298006 is entailed to be subsumed by 2 concept(s):
  64572001  Disease (disorder)
  404684003  Clinical finding (finding)
```

(Real output, verified against a tiny hand-written release with two OWL
axioms — `SubClassOf(:22298006 :64572001)` and
`SubClassOf(:64572001 :404684003)` — where `404684003` is *not* stated
directly on `22298006`; it only shows up because `snomed-classify`
actually ran the completion algorithm, not because it echoed a stated
axiom. Only the file count/elapsed time are illustrative.)

Without a `concept-id`, prints a summary instead:

```sh
$ snomed-cli classify ./Snapshot
loaded 3 file(s), skipped 0 in 1.41ms
OWL axioms: 2 parsed, 0 failed to parse
3 concept(s) classified, 3 entailed subsumption pair(s) total
```

Parses every active `OWLExpression` refset member in the loaded release
(`snomed-owl`) and runs `snomed-classify`'s EL completion algorithm over
the result (spec/13). A row that fails to parse (an OWL construct
`snomed-owl` doesn't support yet) is skipped and reported by
`referencedComponentId`, not a hard error — same philosophy as `load`:
one bad row shouldn't block classifying everything else. Likewise, any
construct `snomed-classify` recognizes but doesn't model
(`ReflexiveObjectProperty`, `SubDataPropertyOf`, `DataHasValue`) is
counted and reported, never silently dropped. Both failure lists cap at
5 shown entries with a "... and N more" tail for large releases.

### `nnf`

```sh
$ snomed-cli nnf ./Snapshot 22298006
loaded 3 file(s), skipped 0 in 1.01ms
OWL axioms: 2 parsed, 0 failed to parse
22298006 necessary normal form:
  is-a (1):
    64572001  Disease (disorder)
  attributes (0):
```

(Real output, verified against the same two-axiom release `classify`'s
example uses. Note the difference: `classify 22298006` shows **both**
`64572001` and `404684003` as entailed supertypes, since it answers "is A
subsumed by B" for every B; `nnf 22298006` shows only `64572001` as an
`is-a` line, since `404684003` is transitively redundant — implied by
`64572001` already, so keeping it as a direct parent would be redundant.
That's necessary normal form's whole point: the minimal set RF2 would
actually ship, not everything entailed. Only the file count/elapsed time
are illustrative.)

Without a `concept-id`, prints a summary instead:

```sh
$ snomed-cli nnf ./Snapshot
loaded 3 file(s), skipped 0 in 0.70ms
OWL axioms: 2 parsed, 0 failed to parse
3 concept(s), 2 proximal parent(s), 0 attribute(s) total
```

Computes `snomed-classify`'s necessary normal form (spec/14) over the
same OWL axioms `classify` collects — proximal (non-redundant) entailed
parents, plus role-grouped, redundancy-reduced attributes. Attribute
lines show `group <N>: <type> (<name>) = <destination> (<name>)`, with
`group 0` for ungrouped attributes. Parse failures and unmodeled
constructs (now also including a stated attribute whose filler isn't a
plain concept) are reported the same way `classify` reports them.

## Design

`src/lib.rs::run(args) -> Result<String, Box<dyn Error>>` holds all the
logic and returns formatted output as a `String` rather than printing
directly — every subcommand is unit- and integration-testable by calling
`run` with a slice of strings and asserting on the returned text, with no
need to spawn the compiled binary. `src/main.rs` is intentionally about ten
lines: collect `std::env::args()`, call `run`, print the result or the
error message, set the exit code.

This crate is deliberately a thin presentation layer. Argument parsing is
hand-rolled (no `clap`) — a continuation of the workspace's
zero-external-dependency stance, not an oversight. See
`AGENTS/cli-engineer.md` in the repo root before adding a subcommand or a
dependency.

## Known gaps

Tracked in the root `tasks.md`: `validate` doesn't check refset
`referencedComponentId` dangling references (documented gap, see
`crates/snomed-store/README.md`), and ECL expressions must be passed as one
pre-quoted argument (no multi-argument reassembly).