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