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//!
13//! # Trademarks
14//!
15//! SNOMED®, SNOMED CT®, and IHTSDO® are registered trademarks of
16//! International Health Terminology Standards Development Organisation
17//! (IHTSDO). Use of the trademarks does not constitute endorsement of this
18//! product by IHTSDO. This project is an independent work: it is not
19//! affiliated with, endorsed by, or certified by SNOMED International, and it
20//! ships no SNOMED CT content.
21
22#![forbid(unsafe_code)]
23// Per spec/rust-no-unsafe/index.md: this workspace contains no `unsafe`, and
24// the compiler enforces that rather than a grep.
25
26mod json;
27
28use std::error::Error;
29use std::fmt::Write as _;
30use std::fs::{self, File};
31use std::io::BufReader;
32use std::path::Path;
33use std::time::Instant;
34
35use snomed_core::components::{Concept, Description, Relationship, RelationshipConcreteValue};
36use snomed_core::sctid::SctId;
37use snomed_rf2::filename::ReleaseFileName;
38use snomed_rf2::reader::Rf2Reader;
39use snomed_rf2::record::Rf2Record;
40use snomed_rf2::refset::{
41    AssociationRefsetMember, AttributeValueRefsetMember, ComponentAnnotationRefsetMember,
42    DescriptionTypeRefsetMember, ExtendedMapRefsetMember, LanguageRefsetMember,
43    MemberAnnotationRefsetMember, ModuleDependencyRefsetMember, MrcmAttributeDomainRefsetMember,
44    MrcmAttributeRangeRefsetMember, MrcmDomainRefsetMember, MrcmModuleScopeRefsetMember,
45    OrderedAssociationRefsetMember, OrderedComponentRefsetMember, OwlExpressionRefsetMember,
46    RefsetDescriptorRefsetMember, SimpleMapRefsetMember, SimpleRefsetMember,
47};
48use snomed_rf2::release_type::ReleaseType;
49use snomed_store::{SnapshotStore, SnapshotStoreBuilder};
50
51use snomed_owl::Axiom;
52
53/// Dispatches on `args[0]` (the subcommand name) and returns the formatted
54/// output. `args` excludes the program name (pass `std::env::args().skip(1)`
55/// collected into a `Vec`, or an equivalent slice in tests).
56pub fn run(args: &[String]) -> Result<String, Box<dyn Error>> {
57    let Some((cmd, rest)) = args.split_first() else {
58        return Ok(usage());
59    };
60    match cmd.as_str() {
61        "sctid" => cmd_sctid(rest),
62        "load" => cmd_load(rest),
63        "lookup" => cmd_lookup(rest),
64        "ecl" => cmd_ecl(rest),
65        "export" => cmd_export(rest),
66        "validate" => cmd_validate(rest),
67        "classify" => cmd_classify(rest),
68        "nnf" => cmd_nnf(rest),
69        "help" | "-h" | "--help" => Ok(usage()),
70        other => Err(format!("unknown command `{other}` (try `snomed-cli help`)").into()),
71    }
72}
73
74fn usage() -> String {
75    let rows: &[(&str, &str)] = &[
76        ("sctid <id>", "validate an SCTID and show its structure"),
77        (
78            "load <release-dir> [--full]",
79            "load a release directory, print a summary",
80        ),
81        (
82            "lookup <release-dir> <id>",
83            "look up a concept: FSN, synonyms, parents, children",
84        ),
85        (
86            "ecl <release-dir> <expression>",
87            "evaluate an ECL expression (quote it)",
88        ),
89        (
90            "export <rf2-file> [output-file]",
91            "convert one RF2 file to NDJSON (stdout if no output file)",
92        ),
93        (
94            "export <release-dir> <output-dir> [--full]",
95            "convert every exportable file in a release directory to NDJSON",
96        ),
97        (
98            "validate <release-dir> [--full]",
99            "check referential integrity and IS-A acyclicity",
100        ),
101        (
102            "classify <release-dir> [concept-id] [--full]",
103            "classify the release's OWL axioms; show one concept's entailed supertypes, or a summary",
104        ),
105        (
106            "nnf <release-dir> [concept-id] [--full]",
107            "necessary normal form: proximal parents + redundancy-reduced attributes, or a summary",
108        ),
109    ];
110    let width = rows.iter().map(|(cmd, _)| cmd.len()).max().unwrap_or(0);
111
112    let mut out = String::new();
113    let _ = writeln!(out, "snomed-cli — local SNOMED CT RF2 toolkit\n");
114    let _ = writeln!(out, "USAGE:");
115    for (cmd, desc) in rows {
116        let _ = writeln!(out, "  snomed-cli {cmd:width$}   {desc}");
117    }
118    let _ = writeln!(
119        out,
120        "\n<release-dir> is an unzipped RF2 release directory. `load`/`lookup`/`ecl`\n\
121         read its Snapshot view by default; `load --full` reads the Full view."
122    );
123    out
124}
125
126fn cmd_sctid(args: &[String]) -> Result<String, Box<dyn Error>> {
127    let raw = args.first().ok_or("usage: sctid <id>")?;
128    let id = SctId::parse(raw)?;
129
130    let mut out = String::new();
131    writeln!(out, "{id}")?;
132    writeln!(
133        out,
134        "  component type: {}",
135        id.component_type()
136            .map(|c| c.to_string())
137            .unwrap_or_else(|| "unknown".to_string())
138    )?;
139    writeln!(
140        out,
141        "  format:         {}",
142        if id.is_long_format() {
143            "long (extension)"
144        } else {
145            "short (International)"
146        }
147    )?;
148    writeln!(out, "  partition:      {:02}", id.partition())?;
149    if let Some(ns) = id.namespace() {
150        writeln!(out, "  namespace:      {ns:07}")?;
151    }
152    writeln!(out, "  item id:        {}", id.item_identifier())?;
153    writeln!(out, "  check digit:    {}", id.check_digit())?;
154    Ok(out)
155}
156
157fn parse_load_args<'a>(
158    args: &'a [String],
159    usage_msg: &'static str,
160) -> Result<(&'a str, ReleaseType), Box<dyn Error>> {
161    let mut dir = None;
162    let mut release_type = ReleaseType::Snapshot;
163    for a in args {
164        match a.as_str() {
165            "--full" => release_type = ReleaseType::Full,
166            other if dir.is_none() => dir = Some(other),
167            other => {
168                return Err(format!("unexpected argument `{other}`\nusage: {usage_msg}").into())
169            }
170        }
171    }
172    let dir = dir.ok_or_else(|| format!("usage: {usage_msg}"))?;
173    Ok((dir, release_type))
174}
175
176fn load(dir: &str, release_type: ReleaseType) -> Result<(SnapshotStore, String), Box<dyn Error>> {
177    let start = Instant::now();
178    let mut builder = SnapshotStoreBuilder::new();
179    let report = builder.load_release_dir(Path::new(dir), release_type)?;
180    let elapsed = start.elapsed();
181
182    let mut out = String::new();
183    writeln!(
184        out,
185        "loaded {} file(s), skipped {} in {elapsed:.2?}",
186        report.loaded.len(),
187        report.skipped.len()
188    )?;
189    for (path, reason) in &report.skipped {
190        writeln!(out, "  skipped {}: {reason}", path.display())?;
191    }
192    let store = builder.build();
193    Ok((store, out))
194}
195
196fn cmd_load(args: &[String]) -> Result<String, Box<dyn Error>> {
197    let (dir, release_type) = parse_load_args(args, "load <release-dir> [--full]")?;
198    let (store, mut out) = load(dir, release_type)?;
199    writeln!(
200        out,
201        "concepts: {} ({} active)",
202        store.concept_count(),
203        store.active_concepts().count()
204    )?;
205    Ok(out)
206}
207
208fn cmd_validate(args: &[String]) -> Result<String, Box<dyn Error>> {
209    let (dir, release_type) = parse_load_args(args, "validate <release-dir> [--full]")?;
210    let (store, mut out) = load(dir, release_type)?;
211    let report = store.validate();
212
213    if report.is_clean() {
214        writeln!(
215            out,
216            "no issues found ({} concepts checked)",
217            store.concept_count()
218        )?;
219        return Ok(out);
220    }
221
222    writeln!(out, "{} issue(s) found:", report.issue_count())?;
223    write_ids(
224        &mut out,
225        "dangling description concept references",
226        &report.dangling_description_concepts,
227    )?;
228    write_ids(
229        &mut out,
230        "dangling relationship source references",
231        &report.dangling_relationship_sources,
232    )?;
233    write_ids(
234        &mut out,
235        "dangling relationship destination references",
236        &report.dangling_relationship_destinations,
237    )?;
238    write_ids(
239        &mut out,
240        "concepts on a cyclic IS-A path",
241        &report.cyclic_concepts,
242    )?;
243    write_ids(
244        &mut out,
245        "active concepts with no IS-A parent (spec/07 rule 2)",
246        &report.rootless_concepts,
247    )?;
248    Ok(out)
249}
250
251fn write_ids(out: &mut String, label: &str, ids: &[SctId]) -> Result<(), Box<dyn Error>> {
252    if ids.is_empty() {
253        return Ok(());
254    }
255    writeln!(out, "  {label} ({}):", ids.len())?;
256    for id in ids {
257        writeln!(out, "    {id}")?;
258    }
259    Ok(())
260}
261
262/// Parses every active OWLExpression refset member in the loaded release
263/// and runs `snomed-classify`'s EL completion over the result. With a
264/// `concept-id`, shows that concept's entailed supertypes (spec/13);
265/// without one, a summary. A row that fails to parse (an OWL construct
266/// `snomed-owl` doesn't support yet, spec/12) is skipped and reported —
267/// same "don't let one bad row block everything else" philosophy as
268/// `load`/`validate`, not a hard error.
269fn cmd_classify(args: &[String]) -> Result<String, Box<dyn Error>> {
270    let usage = "usage: classify <release-dir> [concept-id] [--full]";
271    let mut positional: Vec<&str> = Vec::new();
272    let mut release_type = ReleaseType::Snapshot;
273    for a in args {
274        match a.as_str() {
275            "--full" => release_type = ReleaseType::Full,
276            other => positional.push(other),
277        }
278    }
279    let (dir, concept_id) = match positional.as_slice() {
280        [dir] => (*dir, None),
281        [dir, id] => (*dir, Some(*id)),
282        _ => return Err(usage.into()),
283    };
284
285    let (store, mut out) = load(dir, release_type)?;
286    let axioms = load_owl_axioms(&store, &mut out)?;
287
288    let report = snomed_classify::classify(&axioms);
289    if !report.skipped.is_empty() {
290        writeln!(
291            out,
292            "{} construct(s) not modeled during classification:",
293            report.skipped.len()
294        )?;
295        write_capped(&mut out, &report.skipped, |out, s| writeln!(out, "  {s}"))?;
296    }
297
298    match concept_id {
299        Some(id_str) => {
300            let id = SctId::parse(id_str)?;
301            let mut supers: Vec<SctId> = report.classification.subsumers(id).collect();
302            supers.sort();
303            writeln!(
304                out,
305                "{id} is entailed to be subsumed by {} concept(s):",
306                supers.len()
307            )?;
308            for s in supers {
309                let name = store.fsn(s).map(|d| d.term.as_str()).unwrap_or("?");
310                writeln!(out, "  {s}  {name}")?;
311            }
312        }
313        None => {
314            let concepts: Vec<SctId> = report.classification.concepts().collect();
315            let total_pairs: usize = concepts
316                .iter()
317                .map(|&c| report.classification.subsumers(c).count())
318                .sum();
319            writeln!(
320                out,
321                "{} concept(s) classified, {total_pairs} entailed subsumption pair(s) total",
322                concepts.len()
323            )?;
324        }
325    }
326    Ok(out)
327}
328
329/// Parses every active OWLExpression refset member in `store`, reporting
330/// (into `out`) how many parsed versus failed — a row that fails to parse
331/// (an OWL construct `snomed-owl` doesn't support yet, spec/12) is
332/// skipped and reported, not a hard error, same philosophy as
333/// `load`/`validate`. Shared by `classify` and `nnf`, the two subcommands
334/// that both start from "every OWL axiom in this release".
335fn load_owl_axioms(store: &SnapshotStore, out: &mut String) -> Result<Vec<Axiom>, Box<dyn Error>> {
336    let mut axioms = Vec::new();
337    let mut parse_failures: Vec<(SctId, String)> = Vec::new();
338    for member in store.all_owl_expression_members() {
339        match snomed_owl::parse(&member.owl_expression) {
340            Ok(axiom) => axioms.push(axiom),
341            Err(e) => parse_failures.push((member.core.referenced_component_id, e.to_string())),
342        }
343    }
344    writeln!(
345        out,
346        "OWL axioms: {} parsed, {} failed to parse",
347        axioms.len(),
348        parse_failures.len()
349    )?;
350    write_capped(out, &parse_failures, |out, (id, reason)| {
351        writeln!(out, "  parse error on {id}: {reason}")
352    })?;
353    Ok(axioms)
354}
355
356/// Computes the necessary normal form (spec/14) of the release's OWL
357/// axioms: proximal (non-redundant) entailed parents, plus role-grouped,
358/// redundancy-reduced attributes — built on `snomed-classify`'s
359/// classification, one layer up from `classify` itself. With a
360/// `concept-id`, shows that concept's form; without one, a summary.
361fn cmd_nnf(args: &[String]) -> Result<String, Box<dyn Error>> {
362    let usage = "usage: nnf <release-dir> [concept-id] [--full]";
363    let mut positional: Vec<&str> = Vec::new();
364    let mut release_type = ReleaseType::Snapshot;
365    for a in args {
366        match a.as_str() {
367            "--full" => release_type = ReleaseType::Full,
368            other => positional.push(other),
369        }
370    }
371    let (dir, concept_id) = match positional.as_slice() {
372        [dir] => (*dir, None),
373        [dir, id] => (*dir, Some(*id)),
374        _ => return Err(usage.into()),
375    };
376
377    let (store, mut out) = load(dir, release_type)?;
378    let axioms = load_owl_axioms(&store, &mut out)?;
379
380    let report = snomed_classify::necessary_normal_form(&axioms);
381    if !report.skipped.is_empty() {
382        writeln!(
383            out,
384            "{} construct(s) not modeled while computing necessary normal form:",
385            report.skipped.len()
386        )?;
387        write_capped(&mut out, &report.skipped, |out, s| writeln!(out, "  {s}"))?;
388    }
389
390    match concept_id {
391        Some(id_str) => {
392            let id = SctId::parse(id_str)?;
393            let name = |id: SctId| {
394                store
395                    .fsn(id)
396                    .map(|d| d.term.as_str())
397                    .unwrap_or("?")
398                    .to_string()
399            };
400            match report.forms.get(&id) {
401                Some(form) => {
402                    writeln!(out, "{id} necessary normal form:")?;
403                    writeln!(out, "  is-a ({}):", form.is_a.len())?;
404                    for &parent in &form.is_a {
405                        writeln!(out, "    {parent}  {}", name(parent))?;
406                    }
407                    writeln!(out, "  attributes ({}):", form.attributes.len())?;
408                    for attr in &form.attributes {
409                        writeln!(
410                            out,
411                            "    group {}: {} ({})  =  {} ({})",
412                            attr.group,
413                            attr.type_id,
414                            name(attr.type_id),
415                            attr.destination_id,
416                            name(attr.destination_id)
417                        )?;
418                    }
419                }
420                None => writeln!(
421                    out,
422                    "{id}: no necessary normal form (not named by any input axiom)"
423                )?,
424            }
425        }
426        None => {
427            let concept_count = report.forms.len();
428            let total_parents: usize = report.forms.values().map(|f| f.is_a.len()).sum();
429            let total_attributes: usize = report.forms.values().map(|f| f.attributes.len()).sum();
430            writeln!(
431                out,
432                "{concept_count} concept(s), {total_parents} proximal parent(s), \
433                 {total_attributes} attribute(s) total"
434            )?;
435        }
436    }
437    Ok(out)
438}
439
440/// Writes at most the first 5 items via `write_one`, then a "... and N
441/// more" line if there were more — used for lists that could be large
442/// (parse failures, skipped constructs) where dumping every one would
443/// swamp the summary this subcommand is meant to give.
444fn write_capped<T>(
445    out: &mut String,
446    items: &[T],
447    mut write_one: impl FnMut(&mut String, &T) -> std::fmt::Result,
448) -> Result<(), Box<dyn Error>> {
449    const CAP: usize = 5;
450    for item in items.iter().take(CAP) {
451        write_one(out, item)?;
452    }
453    if items.len() > CAP {
454        writeln!(out, "  ... and {} more", items.len() - CAP)?;
455    }
456    Ok(())
457}
458
459fn cmd_lookup(args: &[String]) -> Result<String, Box<dyn Error>> {
460    let (dir, id_raw) = match args {
461        [dir, id] => (dir.as_str(), id.as_str()),
462        _ => return Err("usage: lookup <release-dir> <id>".into()),
463    };
464    let id = SctId::parse(id_raw)?;
465    let (store, _) = load(dir, ReleaseType::Snapshot)?;
466
467    let mut out = String::new();
468    let Some(concept) = store.concept(id) else {
469        writeln!(out, "{id}: not found in this snapshot")?;
470        return Ok(out);
471    };
472    writeln!(
473        out,
474        "{id}  active={}  module={}",
475        concept.active, concept.module_id
476    )?;
477    if let Some(fsn) = store.fsn(id) {
478        writeln!(out, "  FSN: {}", fsn.term)?;
479    }
480    for syn in store
481        .descriptions_of(id)
482        .filter(|d| d.active && d.is_synonym())
483    {
484        writeln!(out, "  synonym: {}", syn.term)?;
485    }
486    write_related(&mut out, "parents", store.parents(id), &store)?;
487    write_related(&mut out, "children", store.children(id), &store)?;
488    Ok(out)
489}
490
491fn write_related(
492    out: &mut String,
493    label: &str,
494    ids: &[SctId],
495    store: &SnapshotStore,
496) -> Result<(), Box<dyn Error>> {
497    if ids.is_empty() {
498        return Ok(());
499    }
500    writeln!(out, "  {label}:")?;
501    for &id in ids {
502        let name = store.fsn(id).map(|d| d.term.as_str()).unwrap_or("?");
503        writeln!(out, "    {id}  {name}")?;
504    }
505    Ok(())
506}
507
508fn cmd_ecl(args: &[String]) -> Result<String, Box<dyn Error>> {
509    let (dir, expr_str) = match args {
510        [dir, expr] => (dir.as_str(), expr.as_str()),
511        _ => return Err("usage: ecl <release-dir> <expression> (quote the expression)".into()),
512    };
513    let (store, _) = load(dir, ReleaseType::Snapshot)?;
514
515    let expr = snomed_ecl::parse(expr_str)?;
516    let matches = snomed_ecl::evaluate(&expr, &store);
517    let mut sorted: Vec<SctId> = matches.into_iter().collect();
518    sorted.sort();
519
520    let mut out = String::new();
521    writeln!(out, "{} match(es)", sorted.len())?;
522    for id in sorted {
523        let name = store.fsn(id).map(|d| d.term.as_str()).unwrap_or("?");
524        writeln!(out, "{id}  {name}")?;
525    }
526    Ok(out)
527}
528
529/// Dispatches to single-file mode (`export <rf2-file> [output-file]`) or
530/// whole-release-directory mode (`export <release-dir> <output-dir>
531/// [--full]`), auto-detected by whether the first argument is a directory —
532/// so the common single-file shape needs no extra flag.
533fn cmd_export(args: &[String]) -> Result<String, Box<dyn Error>> {
534    let usage =
535        "usage: export <rf2-file> [output-file] | export <release-dir> <output-dir> [--full]";
536    let first = args.first().ok_or(usage)?;
537    if Path::new(first).is_dir() {
538        cmd_export_dir(args)
539    } else {
540        cmd_export_file(args)
541    }
542}
543
544/// Converts one RF2 file to NDJSON, dispatching by (content type, summary)
545/// exactly like `SnapshotStoreBuilder::load_release_dir`'s internal
546/// dispatch — same content types, just serialized instead of stored.
547fn cmd_export_file(args: &[String]) -> Result<String, Box<dyn Error>> {
548    let (input, output) = match args {
549        [input] => (input.as_str(), None),
550        [input, output] => (input.as_str(), Some(output.as_str())),
551        _ => return Err("usage: export <rf2-file> [output-file]".into()),
552    };
553
554    let path = Path::new(input);
555    let file_name = path
556        .file_name()
557        .and_then(|n| n.to_str())
558        .ok_or("input file name is not valid UTF-8")?;
559    let parsed = ReleaseFileName::parse(file_name)?;
560    let ndjson = export_to_ndjson(path, &parsed)?.ok_or_else(|| {
561        format!(
562            "content type `{}` (summary `{}`) is not yet exportable",
563            parsed.content_type, parsed.summary
564        )
565    })?;
566
567    match output {
568        Some(out_path) => {
569            let line_count = ndjson.lines().count();
570            fs::write(out_path, &ndjson)?;
571            Ok(format!("wrote {line_count} line(s) to {out_path}\n"))
572        }
573        None => Ok(ndjson),
574    }
575}
576
577/// Exports every exportable RF2 file under a release directory in one
578/// invocation, mirroring `list_release_files` + per-file dispatch rather
579/// than duplicating directory-walking/release-view-filtering logic here —
580/// that's real domain logic and belongs in `snomed-store` (see
581/// `agents/cli-engineer.md`). One `<file-stem>.ndjson` is written per
582/// exported file, flattened into `out_dir` (release file names are unique
583/// within one release view, so no collisions). Unsupported content types
584/// are skipped and reported, same as `load`; malformed data in a
585/// recognized file is a hard error, same as `load`.
586fn cmd_export_dir(args: &[String]) -> Result<String, Box<dyn Error>> {
587    let usage = "usage: export <release-dir> <output-dir> [--full]";
588    let mut positional = Vec::new();
589    let mut release_type = ReleaseType::Snapshot;
590    for a in args {
591        match a.as_str() {
592            "--full" => release_type = ReleaseType::Full,
593            other => positional.push(other),
594        }
595    }
596    let (dir, out_dir) = match positional.as_slice() {
597        [dir, out_dir] => (*dir, *out_dir),
598        _ => return Err(usage.into()),
599    };
600
601    let files = snomed_store::list_release_files(Path::new(dir), release_type)?;
602    fs::create_dir_all(out_dir)?;
603
604    let mut exported = 0usize;
605    let mut skipped: Vec<(std::path::PathBuf, String)> = Vec::new();
606    for (path, parsed) in &files {
607        match export_to_ndjson(path, parsed)? {
608            Some(ndjson) => {
609                let stem = path
610                    .file_stem()
611                    .and_then(|s| s.to_str())
612                    .ok_or("input file name is not valid UTF-8")?;
613                fs::write(Path::new(out_dir).join(format!("{stem}.ndjson")), &ndjson)?;
614                exported += 1;
615            }
616            None => skipped.push((
617                path.clone(),
618                format!(
619                    "content type `{}` (summary `{}`) is not yet exportable",
620                    parsed.content_type, parsed.summary
621                ),
622            )),
623        }
624    }
625
626    let mut out = String::new();
627    writeln!(
628        out,
629        "exported {exported} file(s), skipped {} to {out_dir}",
630        skipped.len()
631    )?;
632    for (path, reason) in &skipped {
633        writeln!(out, "  skipped {}: {reason}", path.display())?;
634    }
635    Ok(out)
636}
637
638/// `Ok(None)` means the (content type, summary) combination isn't wired up
639/// for export yet — a skip, not an error (mirrors `load.rs::dispatch`'s
640/// `Ok(Some(reason))` shape for the same distinction). `Err` is reserved
641/// for genuine I/O/RF2-parsing failure on a file this function recognized.
642fn export_to_ndjson(path: &Path, f: &ReleaseFileName) -> Result<Option<String>, Box<dyn Error>> {
643    let mut out = String::new();
644    match (f.content_type.as_str(), f.summary.as_str()) {
645        ("Concept", _) => export_rows::<Concept, _>(path, &mut out, json::concept_to_json)?,
646        ("Description", _) | ("TextDefinition", _) => {
647            export_rows::<Description, _>(path, &mut out, json::description_to_json)?
648        }
649        ("Relationship", _) | ("StatedRelationship", _) => {
650            export_rows::<Relationship, _>(path, &mut out, json::relationship_to_json)?
651        }
652        ("RelationshipConcreteValues", _) => export_rows::<RelationshipConcreteValue, _>(
653            path,
654            &mut out,
655            json::relationship_concrete_value_to_json,
656        )?,
657        ("Refset", _) => {
658            export_rows::<SimpleRefsetMember, _>(path, &mut out, json::simple_refset_to_json)?
659        }
660        ("cRefset", "Language") => {
661            export_rows::<LanguageRefsetMember, _>(path, &mut out, json::language_refset_to_json)?
662        }
663        ("cRefset", summary) if summary.contains("Association") => {
664            export_rows::<AssociationRefsetMember, _>(
665                path,
666                &mut out,
667                json::association_refset_to_json,
668            )?
669        }
670        ("cRefset", summary) if summary.contains("AttributeValue") => {
671            export_rows::<AttributeValueRefsetMember, _>(
672                path,
673                &mut out,
674                json::attribute_value_refset_to_json,
675            )?
676        }
677        ("sRefset", "SimpleMap") => export_rows::<SimpleMapRefsetMember, _>(
678            path,
679            &mut out,
680            json::simple_map_refset_to_json,
681        )?,
682        ("sRefset", "OWLExpression") => export_rows::<OwlExpressionRefsetMember, _>(
683            path,
684            &mut out,
685            json::owl_expression_refset_to_json,
686        )?,
687        ("iisssccRefset", _) => export_rows::<ExtendedMapRefsetMember, _>(
688            path,
689            &mut out,
690            json::extended_map_refset_to_json,
691        )?,
692        ("ssRefset", "ModuleDependency") => export_rows::<ModuleDependencyRefsetMember, _>(
693            path,
694            &mut out,
695            json::module_dependency_refset_to_json,
696        )?,
697        ("cciRefset", "RefsetDescriptor") => export_rows::<RefsetDescriptorRefsetMember, _>(
698            path,
699            &mut out,
700            json::refset_descriptor_refset_to_json,
701        )?,
702        ("ciRefset", "DescriptionType") => export_rows::<DescriptionTypeRefsetMember, _>(
703            path,
704            &mut out,
705            json::description_type_refset_to_json,
706        )?,
707        ("cRefset", "MRCMModuleScope") => export_rows::<MrcmModuleScopeRefsetMember, _>(
708            path,
709            &mut out,
710            json::mrcm_module_scope_refset_to_json,
711        )?,
712        ("sssssssRefset", "MRCMDomain") => export_rows::<MrcmDomainRefsetMember, _>(
713            path,
714            &mut out,
715            json::mrcm_domain_refset_to_json,
716        )?,
717        ("cissccRefset", "MRCMAttributeDomain") => {
718            export_rows::<MrcmAttributeDomainRefsetMember, _>(
719                path,
720                &mut out,
721                json::mrcm_attribute_domain_refset_to_json,
722            )?
723        }
724        ("ssccRefset", "MRCMAttributeRange") => export_rows::<MrcmAttributeRangeRefsetMember, _>(
725            path,
726            &mut out,
727            json::mrcm_attribute_range_refset_to_json,
728        )?,
729        ("iRefset", "OrderedComponent") => export_rows::<OrderedComponentRefsetMember, _>(
730            path,
731            &mut out,
732            json::ordered_component_refset_to_json,
733        )?,
734        ("ciRefset", "OrderedAssociation") => export_rows::<OrderedAssociationRefsetMember, _>(
735            path,
736            &mut out,
737            json::ordered_association_refset_to_json,
738        )?,
739        ("scsRefset", "ComponentAnnotationStringValue") => {
740            export_rows::<ComponentAnnotationRefsetMember, _>(
741                path,
742                &mut out,
743                json::component_annotation_refset_to_json,
744            )?
745        }
746        ("sscsRefset", "MemberAnnotationStringValue") => {
747            export_rows::<MemberAnnotationRefsetMember, _>(
748                path,
749                &mut out,
750                json::member_annotation_refset_to_json,
751            )?
752        }
753        (_, _) => return Ok(None),
754    }
755    Ok(Some(out))
756}
757
758fn export_rows<T, F>(path: &Path, out: &mut String, to_json: F) -> Result<(), Box<dyn Error>>
759where
760    T: Rf2Record,
761    F: Fn(&T) -> String,
762{
763    let file = File::open(path)?;
764    let reader = Rf2Reader::<_, T>::new(BufReader::new(file))?;
765    for row in reader {
766        out.push_str(&to_json(&row?));
767        out.push('\n');
768    }
769    Ok(())
770}
771
772#[cfg(test)]
773mod tests {
774    use super::*;
775
776    fn args(strs: &[&str]) -> Vec<String> {
777        strs.iter().map(|s| s.to_string()).collect()
778    }
779
780    #[test]
781    fn no_args_prints_usage() {
782        let out = run(&[]).unwrap();
783        assert!(out.contains("USAGE"));
784    }
785
786    #[test]
787    fn help_prints_usage() {
788        let out = run(&args(&["help"])).unwrap();
789        assert!(out.contains("USAGE"));
790    }
791
792    #[test]
793    fn unknown_command_errors() {
794        let err = run(&args(&["nope"])).unwrap_err();
795        assert!(err.to_string().contains("unknown command"));
796    }
797
798    #[test]
799    fn sctid_reports_structure() {
800        let out = run(&args(&["sctid", "138875005"])).unwrap();
801        assert!(out.contains("component type: Concept"));
802        assert!(out.contains("short (International)"));
803    }
804
805    #[test]
806    fn sctid_rejects_malformed_input() {
807        let err = run(&args(&["sctid", "not-an-id"])).unwrap_err();
808        assert!(!err.to_string().is_empty());
809    }
810
811    #[test]
812    fn load_missing_dir_errors() {
813        let err = run(&args(&["load"])).unwrap_err();
814        assert!(err.to_string().contains("usage"));
815    }
816}