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