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:
Or build the binary once and use it directly:
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
)
No release directory needed — pure SCTID structural validation.
load
) )
)
(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
)
<sctid> <FSN
<sctid> <FSN
(parents/children sections are only printed when non-empty; exact
contents depend on the loaded release.)
ecl
<N> match()
)
Pass the expression as a single (shell-quoted) argument.
export
}
}
)
(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:
)
(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
))
Or, when it finds something:
)
)
)
(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
)
)
)
)
(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:
)
) )
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
)
)
)
)
(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:
)
)))
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).