snomed-cli 0.9.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:

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

Or build the binary once and use it directly:

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

$ 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

$ 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

$ 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

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

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

export

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

$ 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

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

Or, when it finds something:

$ 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

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

$ 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

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

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