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, or classify a release's OWL axioms — 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
<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 ten refset types. 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.
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).