Skip to main content

snomed_cli/
lib.rs

1//! Command-line toolkit over the `snomed` workspace crates: SCTID
2//! validation, release loading, concept lookup, ECL queries.
3//!
4//! This crate is deliberately a thin presentation layer — every subcommand
5//! is a few lines of formatting around calls into `snomed-core`,
6//! `snomed-rf2`, `snomed-store`, and `snomed-ecl`. New domain logic belongs
7//! in those crates, not here (see `AGENTS/cli-engineer.md`).
8//!
9//! [`run`] is the single entry point, returning the formatted output as a
10//! `String` (rather than printing directly) so subcommands are unit- and
11//! integration-testable without spawning the compiled binary.
12
13mod json;
14
15use std::error::Error;
16use std::fmt::Write as _;
17use std::fs::{self, File};
18use std::io::BufReader;
19use std::path::Path;
20use std::time::Instant;
21
22use snomed_core::components::{Concept, Description, Relationship, RelationshipConcreteValue};
23use snomed_core::sctid::SctId;
24use snomed_rf2::filename::ReleaseFileName;
25use snomed_rf2::reader::Rf2Reader;
26use snomed_rf2::record::Rf2Record;
27use snomed_rf2::refset::{
28    AssociationRefsetMember, AttributeValueRefsetMember, DescriptionTypeRefsetMember,
29    ExtendedMapRefsetMember, LanguageRefsetMember, ModuleDependencyRefsetMember,
30    OwlExpressionRefsetMember, RefsetDescriptorRefsetMember, SimpleMapRefsetMember,
31    SimpleRefsetMember,
32};
33use snomed_rf2::release_type::ReleaseType;
34use snomed_store::{SnapshotStore, SnapshotStoreBuilder};
35
36use snomed_owl::Axiom;
37
38/// Dispatches on `args[0]` (the subcommand name) and returns the formatted
39/// output. `args` excludes the program name (pass `std::env::args().skip(1)`
40/// collected into a `Vec`, or an equivalent slice in tests).
41pub fn run(args: &[String]) -> Result<String, Box<dyn Error>> {
42    let Some((cmd, rest)) = args.split_first() else {
43        return Ok(usage());
44    };
45    match cmd.as_str() {
46        "sctid" => cmd_sctid(rest),
47        "load" => cmd_load(rest),
48        "lookup" => cmd_lookup(rest),
49        "ecl" => cmd_ecl(rest),
50        "export" => cmd_export(rest),
51        "validate" => cmd_validate(rest),
52        "classify" => cmd_classify(rest),
53        "help" | "-h" | "--help" => Ok(usage()),
54        other => Err(format!("unknown command `{other}` (try `snomed-cli help`)").into()),
55    }
56}
57
58fn usage() -> String {
59    let rows: &[(&str, &str)] = &[
60        ("sctid <id>", "validate an SCTID and show its structure"),
61        (
62            "load <release-dir> [--full]",
63            "load a release directory, print a summary",
64        ),
65        (
66            "lookup <release-dir> <id>",
67            "look up a concept: FSN, synonyms, parents, children",
68        ),
69        (
70            "ecl <release-dir> <expression>",
71            "evaluate an ECL expression (quote it)",
72        ),
73        (
74            "export <rf2-file> [output-file]",
75            "convert one RF2 file to NDJSON (stdout if no output file)",
76        ),
77        (
78            "export <release-dir> <output-dir> [--full]",
79            "convert every exportable file in a release directory to NDJSON",
80        ),
81        (
82            "validate <release-dir> [--full]",
83            "check referential integrity and IS-A acyclicity",
84        ),
85        (
86            "classify <release-dir> [concept-id] [--full]",
87            "classify the release's OWL axioms; show one concept's entailed supertypes, or a summary",
88        ),
89    ];
90    let width = rows.iter().map(|(cmd, _)| cmd.len()).max().unwrap_or(0);
91
92    let mut out = String::new();
93    let _ = writeln!(out, "snomed-cli — local SNOMED CT RF2 toolkit\n");
94    let _ = writeln!(out, "USAGE:");
95    for (cmd, desc) in rows {
96        let _ = writeln!(out, "  snomed-cli {cmd:width$}   {desc}");
97    }
98    let _ = writeln!(
99        out,
100        "\n<release-dir> is an unzipped RF2 release directory. `load`/`lookup`/`ecl`\n\
101         read its Snapshot view by default; `load --full` reads the Full view."
102    );
103    out
104}
105
106fn cmd_sctid(args: &[String]) -> Result<String, Box<dyn Error>> {
107    let raw = args.first().ok_or("usage: sctid <id>")?;
108    let id = SctId::parse(raw)?;
109
110    let mut out = String::new();
111    writeln!(out, "{id}")?;
112    writeln!(
113        out,
114        "  component type: {}",
115        id.component_type()
116            .map(|c| c.to_string())
117            .unwrap_or_else(|| "unknown".to_string())
118    )?;
119    writeln!(
120        out,
121        "  format:         {}",
122        if id.is_long_format() {
123            "long (extension)"
124        } else {
125            "short (International)"
126        }
127    )?;
128    writeln!(out, "  partition:      {:02}", id.partition())?;
129    if let Some(ns) = id.namespace() {
130        writeln!(out, "  namespace:      {ns:07}")?;
131    }
132    writeln!(out, "  item id:        {}", id.item_identifier())?;
133    writeln!(out, "  check digit:    {}", id.check_digit())?;
134    Ok(out)
135}
136
137fn parse_load_args<'a>(
138    args: &'a [String],
139    usage_msg: &'static str,
140) -> Result<(&'a str, ReleaseType), Box<dyn Error>> {
141    let mut dir = None;
142    let mut release_type = ReleaseType::Snapshot;
143    for a in args {
144        match a.as_str() {
145            "--full" => release_type = ReleaseType::Full,
146            other if dir.is_none() => dir = Some(other),
147            other => {
148                return Err(format!("unexpected argument `{other}`\nusage: {usage_msg}").into())
149            }
150        }
151    }
152    let dir = dir.ok_or_else(|| format!("usage: {usage_msg}"))?;
153    Ok((dir, release_type))
154}
155
156fn load(dir: &str, release_type: ReleaseType) -> Result<(SnapshotStore, String), Box<dyn Error>> {
157    let start = Instant::now();
158    let mut builder = SnapshotStoreBuilder::new();
159    let report = builder.load_release_dir(Path::new(dir), release_type)?;
160    let elapsed = start.elapsed();
161
162    let mut out = String::new();
163    writeln!(
164        out,
165        "loaded {} file(s), skipped {} in {elapsed:.2?}",
166        report.loaded.len(),
167        report.skipped.len()
168    )?;
169    for (path, reason) in &report.skipped {
170        writeln!(out, "  skipped {}: {reason}", path.display())?;
171    }
172    let store = builder.build();
173    Ok((store, out))
174}
175
176fn cmd_load(args: &[String]) -> Result<String, Box<dyn Error>> {
177    let (dir, release_type) = parse_load_args(args, "load <release-dir> [--full]")?;
178    let (store, mut out) = load(dir, release_type)?;
179    writeln!(
180        out,
181        "concepts: {} ({} active)",
182        store.concept_count(),
183        store.active_concepts().count()
184    )?;
185    Ok(out)
186}
187
188fn cmd_validate(args: &[String]) -> Result<String, Box<dyn Error>> {
189    let (dir, release_type) = parse_load_args(args, "validate <release-dir> [--full]")?;
190    let (store, mut out) = load(dir, release_type)?;
191    let report = store.validate();
192
193    if report.is_clean() {
194        writeln!(
195            out,
196            "no issues found ({} concepts checked)",
197            store.concept_count()
198        )?;
199        return Ok(out);
200    }
201
202    writeln!(out, "{} issue(s) found:", report.issue_count())?;
203    write_ids(
204        &mut out,
205        "dangling description concept references",
206        &report.dangling_description_concepts,
207    )?;
208    write_ids(
209        &mut out,
210        "dangling relationship source references",
211        &report.dangling_relationship_sources,
212    )?;
213    write_ids(
214        &mut out,
215        "dangling relationship destination references",
216        &report.dangling_relationship_destinations,
217    )?;
218    write_ids(
219        &mut out,
220        "concepts on a cyclic IS-A path",
221        &report.cyclic_concepts,
222    )?;
223    Ok(out)
224}
225
226fn write_ids(out: &mut String, label: &str, ids: &[SctId]) -> Result<(), Box<dyn Error>> {
227    if ids.is_empty() {
228        return Ok(());
229    }
230    writeln!(out, "  {label} ({}):", ids.len())?;
231    for id in ids {
232        writeln!(out, "    {id}")?;
233    }
234    Ok(())
235}
236
237/// Parses every active OWLExpression refset member in the loaded release
238/// and runs `snomed-classify`'s EL completion over the result. With a
239/// `concept-id`, shows that concept's entailed supertypes (spec/13);
240/// without one, a summary. A row that fails to parse (an OWL construct
241/// `snomed-owl` doesn't support yet, spec/12) is skipped and reported —
242/// same "don't let one bad row block everything else" philosophy as
243/// `load`/`validate`, not a hard error.
244fn cmd_classify(args: &[String]) -> Result<String, Box<dyn Error>> {
245    let usage = "usage: classify <release-dir> [concept-id] [--full]";
246    let mut positional: Vec<&str> = Vec::new();
247    let mut release_type = ReleaseType::Snapshot;
248    for a in args {
249        match a.as_str() {
250            "--full" => release_type = ReleaseType::Full,
251            other => positional.push(other),
252        }
253    }
254    let (dir, concept_id) = match positional.as_slice() {
255        [dir] => (*dir, None),
256        [dir, id] => (*dir, Some(*id)),
257        _ => return Err(usage.into()),
258    };
259
260    let (store, mut out) = load(dir, release_type)?;
261
262    let mut axioms: Vec<Axiom> = Vec::new();
263    let mut parse_failures: Vec<(SctId, String)> = Vec::new();
264    for member in store.all_owl_expression_members() {
265        match snomed_owl::parse(&member.owl_expression) {
266            Ok(axiom) => axioms.push(axiom),
267            Err(e) => parse_failures.push((member.core.referenced_component_id, e.to_string())),
268        }
269    }
270    writeln!(
271        out,
272        "OWL axioms: {} parsed, {} failed to parse",
273        axioms.len(),
274        parse_failures.len()
275    )?;
276    write_capped(&mut out, &parse_failures, |out, (id, reason)| {
277        writeln!(out, "  parse error on {id}: {reason}")
278    })?;
279
280    let report = snomed_classify::classify(&axioms);
281    if !report.skipped.is_empty() {
282        writeln!(
283            out,
284            "{} construct(s) not modeled during classification:",
285            report.skipped.len()
286        )?;
287        write_capped(&mut out, &report.skipped, |out, s| writeln!(out, "  {s}"))?;
288    }
289
290    match concept_id {
291        Some(id_str) => {
292            let id = SctId::parse(id_str)?;
293            let mut supers: Vec<SctId> = report.classification.subsumers(id).collect();
294            supers.sort();
295            writeln!(
296                out,
297                "{id} is entailed to be subsumed by {} concept(s):",
298                supers.len()
299            )?;
300            for s in supers {
301                let name = store.fsn(s).map(|d| d.term.as_str()).unwrap_or("?");
302                writeln!(out, "  {s}  {name}")?;
303            }
304        }
305        None => {
306            let concepts: Vec<SctId> = report.classification.concepts().collect();
307            let total_pairs: usize = concepts
308                .iter()
309                .map(|&c| report.classification.subsumers(c).count())
310                .sum();
311            writeln!(
312                out,
313                "{} concept(s) classified, {total_pairs} entailed subsumption pair(s) total",
314                concepts.len()
315            )?;
316        }
317    }
318    Ok(out)
319}
320
321/// Writes at most the first 5 items via `write_one`, then a "... and N
322/// more" line if there were more — used for lists that could be large
323/// (parse failures, skipped constructs) where dumping every one would
324/// swamp the summary this subcommand is meant to give.
325fn write_capped<T>(
326    out: &mut String,
327    items: &[T],
328    mut write_one: impl FnMut(&mut String, &T) -> std::fmt::Result,
329) -> Result<(), Box<dyn Error>> {
330    const CAP: usize = 5;
331    for item in items.iter().take(CAP) {
332        write_one(out, item)?;
333    }
334    if items.len() > CAP {
335        writeln!(out, "  ... and {} more", items.len() - CAP)?;
336    }
337    Ok(())
338}
339
340fn cmd_lookup(args: &[String]) -> Result<String, Box<dyn Error>> {
341    let (dir, id_raw) = match args {
342        [dir, id] => (dir.as_str(), id.as_str()),
343        _ => return Err("usage: lookup <release-dir> <id>".into()),
344    };
345    let id = SctId::parse(id_raw)?;
346    let (store, _) = load(dir, ReleaseType::Snapshot)?;
347
348    let mut out = String::new();
349    let Some(concept) = store.concept(id) else {
350        writeln!(out, "{id}: not found in this snapshot")?;
351        return Ok(out);
352    };
353    writeln!(
354        out,
355        "{id}  active={}  module={}",
356        concept.active, concept.module_id
357    )?;
358    if let Some(fsn) = store.fsn(id) {
359        writeln!(out, "  FSN: {}", fsn.term)?;
360    }
361    for syn in store
362        .descriptions_of(id)
363        .filter(|d| d.active && d.is_synonym())
364    {
365        writeln!(out, "  synonym: {}", syn.term)?;
366    }
367    write_related(&mut out, "parents", store.parents(id), &store)?;
368    write_related(&mut out, "children", store.children(id), &store)?;
369    Ok(out)
370}
371
372fn write_related(
373    out: &mut String,
374    label: &str,
375    ids: &[SctId],
376    store: &SnapshotStore,
377) -> Result<(), Box<dyn Error>> {
378    if ids.is_empty() {
379        return Ok(());
380    }
381    writeln!(out, "  {label}:")?;
382    for &id in ids {
383        let name = store.fsn(id).map(|d| d.term.as_str()).unwrap_or("?");
384        writeln!(out, "    {id}  {name}")?;
385    }
386    Ok(())
387}
388
389fn cmd_ecl(args: &[String]) -> Result<String, Box<dyn Error>> {
390    let (dir, expr_str) = match args {
391        [dir, expr] => (dir.as_str(), expr.as_str()),
392        _ => return Err("usage: ecl <release-dir> <expression> (quote the expression)".into()),
393    };
394    let (store, _) = load(dir, ReleaseType::Snapshot)?;
395
396    let expr = snomed_ecl::parse(expr_str)?;
397    let matches = snomed_ecl::evaluate(&expr, &store);
398    let mut sorted: Vec<SctId> = matches.into_iter().collect();
399    sorted.sort();
400
401    let mut out = String::new();
402    writeln!(out, "{} match(es)", sorted.len())?;
403    for id in sorted {
404        let name = store.fsn(id).map(|d| d.term.as_str()).unwrap_or("?");
405        writeln!(out, "{id}  {name}")?;
406    }
407    Ok(out)
408}
409
410/// Dispatches to single-file mode (`export <rf2-file> [output-file]`) or
411/// whole-release-directory mode (`export <release-dir> <output-dir>
412/// [--full]`), auto-detected by whether the first argument is a directory —
413/// so the common single-file shape needs no extra flag.
414fn cmd_export(args: &[String]) -> Result<String, Box<dyn Error>> {
415    let usage =
416        "usage: export <rf2-file> [output-file] | export <release-dir> <output-dir> [--full]";
417    let first = args.first().ok_or(usage)?;
418    if Path::new(first).is_dir() {
419        cmd_export_dir(args)
420    } else {
421        cmd_export_file(args)
422    }
423}
424
425/// Converts one RF2 file to NDJSON, dispatching by (content type, summary)
426/// exactly like `SnapshotStoreBuilder::load_release_dir`'s internal
427/// dispatch — same content types, just serialized instead of stored.
428fn cmd_export_file(args: &[String]) -> Result<String, Box<dyn Error>> {
429    let (input, output) = match args {
430        [input] => (input.as_str(), None),
431        [input, output] => (input.as_str(), Some(output.as_str())),
432        _ => return Err("usage: export <rf2-file> [output-file]".into()),
433    };
434
435    let path = Path::new(input);
436    let file_name = path
437        .file_name()
438        .and_then(|n| n.to_str())
439        .ok_or("input file name is not valid UTF-8")?;
440    let parsed = ReleaseFileName::parse(file_name)?;
441    let ndjson = export_to_ndjson(path, &parsed)?.ok_or_else(|| {
442        format!(
443            "content type `{}` (summary `{}`) is not yet exportable",
444            parsed.content_type, parsed.summary
445        )
446    })?;
447
448    match output {
449        Some(out_path) => {
450            let line_count = ndjson.lines().count();
451            fs::write(out_path, &ndjson)?;
452            Ok(format!("wrote {line_count} line(s) to {out_path}\n"))
453        }
454        None => Ok(ndjson),
455    }
456}
457
458/// Exports every exportable RF2 file under a release directory in one
459/// invocation, mirroring `list_release_files` + per-file dispatch rather
460/// than duplicating directory-walking/release-view-filtering logic here —
461/// that's real domain logic and belongs in `snomed-store` (see
462/// `AGENTS/cli-engineer.md`). One `<file-stem>.ndjson` is written per
463/// exported file, flattened into `out_dir` (release file names are unique
464/// within one release view, so no collisions). Unsupported content types
465/// are skipped and reported, same as `load`; malformed data in a
466/// recognized file is a hard error, same as `load`.
467fn cmd_export_dir(args: &[String]) -> Result<String, Box<dyn Error>> {
468    let usage = "usage: export <release-dir> <output-dir> [--full]";
469    let mut positional = Vec::new();
470    let mut release_type = ReleaseType::Snapshot;
471    for a in args {
472        match a.as_str() {
473            "--full" => release_type = ReleaseType::Full,
474            other => positional.push(other),
475        }
476    }
477    let (dir, out_dir) = match positional.as_slice() {
478        [dir, out_dir] => (*dir, *out_dir),
479        _ => return Err(usage.into()),
480    };
481
482    let files = snomed_store::list_release_files(Path::new(dir), release_type)?;
483    fs::create_dir_all(out_dir)?;
484
485    let mut exported = 0usize;
486    let mut skipped: Vec<(std::path::PathBuf, String)> = Vec::new();
487    for (path, parsed) in &files {
488        match export_to_ndjson(path, parsed)? {
489            Some(ndjson) => {
490                let stem = path
491                    .file_stem()
492                    .and_then(|s| s.to_str())
493                    .ok_or("input file name is not valid UTF-8")?;
494                fs::write(Path::new(out_dir).join(format!("{stem}.ndjson")), &ndjson)?;
495                exported += 1;
496            }
497            None => skipped.push((
498                path.clone(),
499                format!(
500                    "content type `{}` (summary `{}`) is not yet exportable",
501                    parsed.content_type, parsed.summary
502                ),
503            )),
504        }
505    }
506
507    let mut out = String::new();
508    writeln!(
509        out,
510        "exported {exported} file(s), skipped {} to {out_dir}",
511        skipped.len()
512    )?;
513    for (path, reason) in &skipped {
514        writeln!(out, "  skipped {}: {reason}", path.display())?;
515    }
516    Ok(out)
517}
518
519/// `Ok(None)` means the (content type, summary) combination isn't wired up
520/// for export yet — a skip, not an error (mirrors `load.rs::dispatch`'s
521/// `Ok(Some(reason))` shape for the same distinction). `Err` is reserved
522/// for genuine I/O/RF2-parsing failure on a file this function recognized.
523fn export_to_ndjson(path: &Path, f: &ReleaseFileName) -> Result<Option<String>, Box<dyn Error>> {
524    let mut out = String::new();
525    match (f.content_type.as_str(), f.summary.as_str()) {
526        ("Concept", _) => export_rows::<Concept, _>(path, &mut out, json::concept_to_json)?,
527        ("Description", _) | ("TextDefinition", _) => {
528            export_rows::<Description, _>(path, &mut out, json::description_to_json)?
529        }
530        ("Relationship", _) | ("StatedRelationship", _) => {
531            export_rows::<Relationship, _>(path, &mut out, json::relationship_to_json)?
532        }
533        ("RelationshipConcreteValues", _) => export_rows::<RelationshipConcreteValue, _>(
534            path,
535            &mut out,
536            json::relationship_concrete_value_to_json,
537        )?,
538        ("Refset", _) => {
539            export_rows::<SimpleRefsetMember, _>(path, &mut out, json::simple_refset_to_json)?
540        }
541        ("cRefset", "Language") => {
542            export_rows::<LanguageRefsetMember, _>(path, &mut out, json::language_refset_to_json)?
543        }
544        ("cRefset", summary) if summary.contains("Association") => {
545            export_rows::<AssociationRefsetMember, _>(
546                path,
547                &mut out,
548                json::association_refset_to_json,
549            )?
550        }
551        ("cRefset", summary) if summary.contains("AttributeValue") => {
552            export_rows::<AttributeValueRefsetMember, _>(
553                path,
554                &mut out,
555                json::attribute_value_refset_to_json,
556            )?
557        }
558        ("sRefset", "SimpleMap") => export_rows::<SimpleMapRefsetMember, _>(
559            path,
560            &mut out,
561            json::simple_map_refset_to_json,
562        )?,
563        ("sRefset", "OWLExpression") => export_rows::<OwlExpressionRefsetMember, _>(
564            path,
565            &mut out,
566            json::owl_expression_refset_to_json,
567        )?,
568        ("iisssccRefset", _) => export_rows::<ExtendedMapRefsetMember, _>(
569            path,
570            &mut out,
571            json::extended_map_refset_to_json,
572        )?,
573        ("ssRefset", "ModuleDependency") => export_rows::<ModuleDependencyRefsetMember, _>(
574            path,
575            &mut out,
576            json::module_dependency_refset_to_json,
577        )?,
578        ("cciRefset", "RefsetDescriptor") => export_rows::<RefsetDescriptorRefsetMember, _>(
579            path,
580            &mut out,
581            json::refset_descriptor_refset_to_json,
582        )?,
583        ("ciRefset", "DescriptionType") => export_rows::<DescriptionTypeRefsetMember, _>(
584            path,
585            &mut out,
586            json::description_type_refset_to_json,
587        )?,
588        (_, _) => return Ok(None),
589    }
590    Ok(Some(out))
591}
592
593fn export_rows<T, F>(path: &Path, out: &mut String, to_json: F) -> Result<(), Box<dyn Error>>
594where
595    T: Rf2Record,
596    F: Fn(&T) -> String,
597{
598    let file = File::open(path)?;
599    let reader = Rf2Reader::<_, T>::new(BufReader::new(file))?;
600    for row in reader {
601        out.push_str(&to_json(&row?));
602        out.push('\n');
603    }
604    Ok(())
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610
611    fn args(strs: &[&str]) -> Vec<String> {
612        strs.iter().map(|s| s.to_string()).collect()
613    }
614
615    #[test]
616    fn no_args_prints_usage() {
617        let out = run(&[]).unwrap();
618        assert!(out.contains("USAGE"));
619    }
620
621    #[test]
622    fn help_prints_usage() {
623        let out = run(&args(&["help"])).unwrap();
624        assert!(out.contains("USAGE"));
625    }
626
627    #[test]
628    fn unknown_command_errors() {
629        let err = run(&args(&["nope"])).unwrap_err();
630        assert!(err.to_string().contains("unknown command"));
631    }
632
633    #[test]
634    fn sctid_reports_structure() {
635        let out = run(&args(&["sctid", "138875005"])).unwrap();
636        assert!(out.contains("component type: Concept"));
637        assert!(out.contains("short (International)"));
638    }
639
640    #[test]
641    fn sctid_rejects_malformed_input() {
642        let err = run(&args(&["sctid", "not-an-id"])).unwrap_err();
643        assert!(!err.to_string().is_empty());
644    }
645
646    #[test]
647    fn load_missing_dir_errors() {
648        let err = run(&args(&["load"])).unwrap_err();
649        assert!(err.to_string().contains("usage"));
650    }
651}