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    write_ids(
231        &mut out,
232        "active concepts with no IS-A parent (spec/07 rule 2)",
233        &report.rootless_concepts,
234    )?;
235    Ok(out)
236}
237
238fn write_ids(out: &mut String, label: &str, ids: &[SctId]) -> Result<(), Box<dyn Error>> {
239    if ids.is_empty() {
240        return Ok(());
241    }
242    writeln!(out, "  {label} ({}):", ids.len())?;
243    for id in ids {
244        writeln!(out, "    {id}")?;
245    }
246    Ok(())
247}
248
249/// Parses every active OWLExpression refset member in the loaded release
250/// and runs `snomed-classify`'s EL completion over the result. With a
251/// `concept-id`, shows that concept's entailed supertypes (spec/13);
252/// without one, a summary. A row that fails to parse (an OWL construct
253/// `snomed-owl` doesn't support yet, spec/12) is skipped and reported —
254/// same "don't let one bad row block everything else" philosophy as
255/// `load`/`validate`, not a hard error.
256fn cmd_classify(args: &[String]) -> Result<String, Box<dyn Error>> {
257    let usage = "usage: classify <release-dir> [concept-id] [--full]";
258    let mut positional: Vec<&str> = Vec::new();
259    let mut release_type = ReleaseType::Snapshot;
260    for a in args {
261        match a.as_str() {
262            "--full" => release_type = ReleaseType::Full,
263            other => positional.push(other),
264        }
265    }
266    let (dir, concept_id) = match positional.as_slice() {
267        [dir] => (*dir, None),
268        [dir, id] => (*dir, Some(*id)),
269        _ => return Err(usage.into()),
270    };
271
272    let (store, mut out) = load(dir, release_type)?;
273    let axioms = load_owl_axioms(&store, &mut out)?;
274
275    let report = snomed_classify::classify(&axioms);
276    if !report.skipped.is_empty() {
277        writeln!(
278            out,
279            "{} construct(s) not modeled during classification:",
280            report.skipped.len()
281        )?;
282        write_capped(&mut out, &report.skipped, |out, s| writeln!(out, "  {s}"))?;
283    }
284
285    match concept_id {
286        Some(id_str) => {
287            let id = SctId::parse(id_str)?;
288            let mut supers: Vec<SctId> = report.classification.subsumers(id).collect();
289            supers.sort();
290            writeln!(
291                out,
292                "{id} is entailed to be subsumed by {} concept(s):",
293                supers.len()
294            )?;
295            for s in supers {
296                let name = store.fsn(s).map(|d| d.term.as_str()).unwrap_or("?");
297                writeln!(out, "  {s}  {name}")?;
298            }
299        }
300        None => {
301            let concepts: Vec<SctId> = report.classification.concepts().collect();
302            let total_pairs: usize = concepts
303                .iter()
304                .map(|&c| report.classification.subsumers(c).count())
305                .sum();
306            writeln!(
307                out,
308                "{} concept(s) classified, {total_pairs} entailed subsumption pair(s) total",
309                concepts.len()
310            )?;
311        }
312    }
313    Ok(out)
314}
315
316/// Parses every active OWLExpression refset member in `store`, reporting
317/// (into `out`) how many parsed versus failed — a row that fails to parse
318/// (an OWL construct `snomed-owl` doesn't support yet, spec/12) is
319/// skipped and reported, not a hard error, same philosophy as
320/// `load`/`validate`. Shared by `classify` and `nnf`, the two subcommands
321/// that both start from "every OWL axiom in this release".
322fn load_owl_axioms(store: &SnapshotStore, out: &mut String) -> Result<Vec<Axiom>, Box<dyn Error>> {
323    let mut axioms = Vec::new();
324    let mut parse_failures: Vec<(SctId, String)> = Vec::new();
325    for member in store.all_owl_expression_members() {
326        match snomed_owl::parse(&member.owl_expression) {
327            Ok(axiom) => axioms.push(axiom),
328            Err(e) => parse_failures.push((member.core.referenced_component_id, e.to_string())),
329        }
330    }
331    writeln!(
332        out,
333        "OWL axioms: {} parsed, {} failed to parse",
334        axioms.len(),
335        parse_failures.len()
336    )?;
337    write_capped(out, &parse_failures, |out, (id, reason)| {
338        writeln!(out, "  parse error on {id}: {reason}")
339    })?;
340    Ok(axioms)
341}
342
343/// Computes the necessary normal form (spec/14) of the release's OWL
344/// axioms: proximal (non-redundant) entailed parents, plus role-grouped,
345/// redundancy-reduced attributes — built on `snomed-classify`'s
346/// classification, one layer up from `classify` itself. With a
347/// `concept-id`, shows that concept's form; without one, a summary.
348fn cmd_nnf(args: &[String]) -> Result<String, Box<dyn Error>> {
349    let usage = "usage: nnf <release-dir> [concept-id] [--full]";
350    let mut positional: Vec<&str> = Vec::new();
351    let mut release_type = ReleaseType::Snapshot;
352    for a in args {
353        match a.as_str() {
354            "--full" => release_type = ReleaseType::Full,
355            other => positional.push(other),
356        }
357    }
358    let (dir, concept_id) = match positional.as_slice() {
359        [dir] => (*dir, None),
360        [dir, id] => (*dir, Some(*id)),
361        _ => return Err(usage.into()),
362    };
363
364    let (store, mut out) = load(dir, release_type)?;
365    let axioms = load_owl_axioms(&store, &mut out)?;
366
367    let report = snomed_classify::necessary_normal_form(&axioms);
368    if !report.skipped.is_empty() {
369        writeln!(
370            out,
371            "{} construct(s) not modeled while computing necessary normal form:",
372            report.skipped.len()
373        )?;
374        write_capped(&mut out, &report.skipped, |out, s| writeln!(out, "  {s}"))?;
375    }
376
377    match concept_id {
378        Some(id_str) => {
379            let id = SctId::parse(id_str)?;
380            let name = |id: SctId| {
381                store
382                    .fsn(id)
383                    .map(|d| d.term.as_str())
384                    .unwrap_or("?")
385                    .to_string()
386            };
387            match report.forms.get(&id) {
388                Some(form) => {
389                    writeln!(out, "{id} necessary normal form:")?;
390                    writeln!(out, "  is-a ({}):", form.is_a.len())?;
391                    for &parent in &form.is_a {
392                        writeln!(out, "    {parent}  {}", name(parent))?;
393                    }
394                    writeln!(out, "  attributes ({}):", form.attributes.len())?;
395                    for attr in &form.attributes {
396                        writeln!(
397                            out,
398                            "    group {}: {} ({})  =  {} ({})",
399                            attr.group,
400                            attr.type_id,
401                            name(attr.type_id),
402                            attr.destination_id,
403                            name(attr.destination_id)
404                        )?;
405                    }
406                }
407                None => writeln!(
408                    out,
409                    "{id}: no necessary normal form (not named by any input axiom)"
410                )?,
411            }
412        }
413        None => {
414            let concept_count = report.forms.len();
415            let total_parents: usize = report.forms.values().map(|f| f.is_a.len()).sum();
416            let total_attributes: usize = report.forms.values().map(|f| f.attributes.len()).sum();
417            writeln!(
418                out,
419                "{concept_count} concept(s), {total_parents} proximal parent(s), \
420                 {total_attributes} attribute(s) total"
421            )?;
422        }
423    }
424    Ok(out)
425}
426
427/// Writes at most the first 5 items via `write_one`, then a "... and N
428/// more" line if there were more — used for lists that could be large
429/// (parse failures, skipped constructs) where dumping every one would
430/// swamp the summary this subcommand is meant to give.
431fn write_capped<T>(
432    out: &mut String,
433    items: &[T],
434    mut write_one: impl FnMut(&mut String, &T) -> std::fmt::Result,
435) -> Result<(), Box<dyn Error>> {
436    const CAP: usize = 5;
437    for item in items.iter().take(CAP) {
438        write_one(out, item)?;
439    }
440    if items.len() > CAP {
441        writeln!(out, "  ... and {} more", items.len() - CAP)?;
442    }
443    Ok(())
444}
445
446fn cmd_lookup(args: &[String]) -> Result<String, Box<dyn Error>> {
447    let (dir, id_raw) = match args {
448        [dir, id] => (dir.as_str(), id.as_str()),
449        _ => return Err("usage: lookup <release-dir> <id>".into()),
450    };
451    let id = SctId::parse(id_raw)?;
452    let (store, _) = load(dir, ReleaseType::Snapshot)?;
453
454    let mut out = String::new();
455    let Some(concept) = store.concept(id) else {
456        writeln!(out, "{id}: not found in this snapshot")?;
457        return Ok(out);
458    };
459    writeln!(
460        out,
461        "{id}  active={}  module={}",
462        concept.active, concept.module_id
463    )?;
464    if let Some(fsn) = store.fsn(id) {
465        writeln!(out, "  FSN: {}", fsn.term)?;
466    }
467    for syn in store
468        .descriptions_of(id)
469        .filter(|d| d.active && d.is_synonym())
470    {
471        writeln!(out, "  synonym: {}", syn.term)?;
472    }
473    write_related(&mut out, "parents", store.parents(id), &store)?;
474    write_related(&mut out, "children", store.children(id), &store)?;
475    Ok(out)
476}
477
478fn write_related(
479    out: &mut String,
480    label: &str,
481    ids: &[SctId],
482    store: &SnapshotStore,
483) -> Result<(), Box<dyn Error>> {
484    if ids.is_empty() {
485        return Ok(());
486    }
487    writeln!(out, "  {label}:")?;
488    for &id in ids {
489        let name = store.fsn(id).map(|d| d.term.as_str()).unwrap_or("?");
490        writeln!(out, "    {id}  {name}")?;
491    }
492    Ok(())
493}
494
495fn cmd_ecl(args: &[String]) -> Result<String, Box<dyn Error>> {
496    let (dir, expr_str) = match args {
497        [dir, expr] => (dir.as_str(), expr.as_str()),
498        _ => return Err("usage: ecl <release-dir> <expression> (quote the expression)".into()),
499    };
500    let (store, _) = load(dir, ReleaseType::Snapshot)?;
501
502    let expr = snomed_ecl::parse(expr_str)?;
503    let matches = snomed_ecl::evaluate(&expr, &store);
504    let mut sorted: Vec<SctId> = matches.into_iter().collect();
505    sorted.sort();
506
507    let mut out = String::new();
508    writeln!(out, "{} match(es)", sorted.len())?;
509    for id in sorted {
510        let name = store.fsn(id).map(|d| d.term.as_str()).unwrap_or("?");
511        writeln!(out, "{id}  {name}")?;
512    }
513    Ok(out)
514}
515
516/// Dispatches to single-file mode (`export <rf2-file> [output-file]`) or
517/// whole-release-directory mode (`export <release-dir> <output-dir>
518/// [--full]`), auto-detected by whether the first argument is a directory —
519/// so the common single-file shape needs no extra flag.
520fn cmd_export(args: &[String]) -> Result<String, Box<dyn Error>> {
521    let usage =
522        "usage: export <rf2-file> [output-file] | export <release-dir> <output-dir> [--full]";
523    let first = args.first().ok_or(usage)?;
524    if Path::new(first).is_dir() {
525        cmd_export_dir(args)
526    } else {
527        cmd_export_file(args)
528    }
529}
530
531/// Converts one RF2 file to NDJSON, dispatching by (content type, summary)
532/// exactly like `SnapshotStoreBuilder::load_release_dir`'s internal
533/// dispatch — same content types, just serialized instead of stored.
534fn cmd_export_file(args: &[String]) -> Result<String, Box<dyn Error>> {
535    let (input, output) = match args {
536        [input] => (input.as_str(), None),
537        [input, output] => (input.as_str(), Some(output.as_str())),
538        _ => return Err("usage: export <rf2-file> [output-file]".into()),
539    };
540
541    let path = Path::new(input);
542    let file_name = path
543        .file_name()
544        .and_then(|n| n.to_str())
545        .ok_or("input file name is not valid UTF-8")?;
546    let parsed = ReleaseFileName::parse(file_name)?;
547    let ndjson = export_to_ndjson(path, &parsed)?.ok_or_else(|| {
548        format!(
549            "content type `{}` (summary `{}`) is not yet exportable",
550            parsed.content_type, parsed.summary
551        )
552    })?;
553
554    match output {
555        Some(out_path) => {
556            let line_count = ndjson.lines().count();
557            fs::write(out_path, &ndjson)?;
558            Ok(format!("wrote {line_count} line(s) to {out_path}\n"))
559        }
560        None => Ok(ndjson),
561    }
562}
563
564/// Exports every exportable RF2 file under a release directory in one
565/// invocation, mirroring `list_release_files` + per-file dispatch rather
566/// than duplicating directory-walking/release-view-filtering logic here —
567/// that's real domain logic and belongs in `snomed-store` (see
568/// `agents/cli-engineer.md`). One `<file-stem>.ndjson` is written per
569/// exported file, flattened into `out_dir` (release file names are unique
570/// within one release view, so no collisions). Unsupported content types
571/// are skipped and reported, same as `load`; malformed data in a
572/// recognized file is a hard error, same as `load`.
573fn cmd_export_dir(args: &[String]) -> Result<String, Box<dyn Error>> {
574    let usage = "usage: export <release-dir> <output-dir> [--full]";
575    let mut positional = Vec::new();
576    let mut release_type = ReleaseType::Snapshot;
577    for a in args {
578        match a.as_str() {
579            "--full" => release_type = ReleaseType::Full,
580            other => positional.push(other),
581        }
582    }
583    let (dir, out_dir) = match positional.as_slice() {
584        [dir, out_dir] => (*dir, *out_dir),
585        _ => return Err(usage.into()),
586    };
587
588    let files = snomed_store::list_release_files(Path::new(dir), release_type)?;
589    fs::create_dir_all(out_dir)?;
590
591    let mut exported = 0usize;
592    let mut skipped: Vec<(std::path::PathBuf, String)> = Vec::new();
593    for (path, parsed) in &files {
594        match export_to_ndjson(path, parsed)? {
595            Some(ndjson) => {
596                let stem = path
597                    .file_stem()
598                    .and_then(|s| s.to_str())
599                    .ok_or("input file name is not valid UTF-8")?;
600                fs::write(Path::new(out_dir).join(format!("{stem}.ndjson")), &ndjson)?;
601                exported += 1;
602            }
603            None => skipped.push((
604                path.clone(),
605                format!(
606                    "content type `{}` (summary `{}`) is not yet exportable",
607                    parsed.content_type, parsed.summary
608                ),
609            )),
610        }
611    }
612
613    let mut out = String::new();
614    writeln!(
615        out,
616        "exported {exported} file(s), skipped {} to {out_dir}",
617        skipped.len()
618    )?;
619    for (path, reason) in &skipped {
620        writeln!(out, "  skipped {}: {reason}", path.display())?;
621    }
622    Ok(out)
623}
624
625/// `Ok(None)` means the (content type, summary) combination isn't wired up
626/// for export yet — a skip, not an error (mirrors `load.rs::dispatch`'s
627/// `Ok(Some(reason))` shape for the same distinction). `Err` is reserved
628/// for genuine I/O/RF2-parsing failure on a file this function recognized.
629fn export_to_ndjson(path: &Path, f: &ReleaseFileName) -> Result<Option<String>, Box<dyn Error>> {
630    let mut out = String::new();
631    match (f.content_type.as_str(), f.summary.as_str()) {
632        ("Concept", _) => export_rows::<Concept, _>(path, &mut out, json::concept_to_json)?,
633        ("Description", _) | ("TextDefinition", _) => {
634            export_rows::<Description, _>(path, &mut out, json::description_to_json)?
635        }
636        ("Relationship", _) | ("StatedRelationship", _) => {
637            export_rows::<Relationship, _>(path, &mut out, json::relationship_to_json)?
638        }
639        ("RelationshipConcreteValues", _) => export_rows::<RelationshipConcreteValue, _>(
640            path,
641            &mut out,
642            json::relationship_concrete_value_to_json,
643        )?,
644        ("Refset", _) => {
645            export_rows::<SimpleRefsetMember, _>(path, &mut out, json::simple_refset_to_json)?
646        }
647        ("cRefset", "Language") => {
648            export_rows::<LanguageRefsetMember, _>(path, &mut out, json::language_refset_to_json)?
649        }
650        ("cRefset", summary) if summary.contains("Association") => {
651            export_rows::<AssociationRefsetMember, _>(
652                path,
653                &mut out,
654                json::association_refset_to_json,
655            )?
656        }
657        ("cRefset", summary) if summary.contains("AttributeValue") => {
658            export_rows::<AttributeValueRefsetMember, _>(
659                path,
660                &mut out,
661                json::attribute_value_refset_to_json,
662            )?
663        }
664        ("sRefset", "SimpleMap") => export_rows::<SimpleMapRefsetMember, _>(
665            path,
666            &mut out,
667            json::simple_map_refset_to_json,
668        )?,
669        ("sRefset", "OWLExpression") => export_rows::<OwlExpressionRefsetMember, _>(
670            path,
671            &mut out,
672            json::owl_expression_refset_to_json,
673        )?,
674        ("iisssccRefset", _) => export_rows::<ExtendedMapRefsetMember, _>(
675            path,
676            &mut out,
677            json::extended_map_refset_to_json,
678        )?,
679        ("ssRefset", "ModuleDependency") => export_rows::<ModuleDependencyRefsetMember, _>(
680            path,
681            &mut out,
682            json::module_dependency_refset_to_json,
683        )?,
684        ("cciRefset", "RefsetDescriptor") => export_rows::<RefsetDescriptorRefsetMember, _>(
685            path,
686            &mut out,
687            json::refset_descriptor_refset_to_json,
688        )?,
689        ("ciRefset", "DescriptionType") => export_rows::<DescriptionTypeRefsetMember, _>(
690            path,
691            &mut out,
692            json::description_type_refset_to_json,
693        )?,
694        ("cRefset", "MRCMModuleScope") => export_rows::<MrcmModuleScopeRefsetMember, _>(
695            path,
696            &mut out,
697            json::mrcm_module_scope_refset_to_json,
698        )?,
699        ("sssssssRefset", "MRCMDomain") => export_rows::<MrcmDomainRefsetMember, _>(
700            path,
701            &mut out,
702            json::mrcm_domain_refset_to_json,
703        )?,
704        ("cissccRefset", "MRCMAttributeDomain") => {
705            export_rows::<MrcmAttributeDomainRefsetMember, _>(
706                path,
707                &mut out,
708                json::mrcm_attribute_domain_refset_to_json,
709            )?
710        }
711        ("ssccRefset", "MRCMAttributeRange") => export_rows::<MrcmAttributeRangeRefsetMember, _>(
712            path,
713            &mut out,
714            json::mrcm_attribute_range_refset_to_json,
715        )?,
716        ("iRefset", "OrderedComponent") => export_rows::<OrderedComponentRefsetMember, _>(
717            path,
718            &mut out,
719            json::ordered_component_refset_to_json,
720        )?,
721        ("ciRefset", "OrderedAssociation") => export_rows::<OrderedAssociationRefsetMember, _>(
722            path,
723            &mut out,
724            json::ordered_association_refset_to_json,
725        )?,
726        ("scsRefset", "ComponentAnnotationStringValue") => {
727            export_rows::<ComponentAnnotationRefsetMember, _>(
728                path,
729                &mut out,
730                json::component_annotation_refset_to_json,
731            )?
732        }
733        ("sscsRefset", "MemberAnnotationStringValue") => {
734            export_rows::<MemberAnnotationRefsetMember, _>(
735                path,
736                &mut out,
737                json::member_annotation_refset_to_json,
738            )?
739        }
740        (_, _) => return Ok(None),
741    }
742    Ok(Some(out))
743}
744
745fn export_rows<T, F>(path: &Path, out: &mut String, to_json: F) -> Result<(), Box<dyn Error>>
746where
747    T: Rf2Record,
748    F: Fn(&T) -> String,
749{
750    let file = File::open(path)?;
751    let reader = Rf2Reader::<_, T>::new(BufReader::new(file))?;
752    for row in reader {
753        out.push_str(&to_json(&row?));
754        out.push('\n');
755    }
756    Ok(())
757}
758
759#[cfg(test)]
760mod tests {
761    use super::*;
762
763    fn args(strs: &[&str]) -> Vec<String> {
764        strs.iter().map(|s| s.to_string()).collect()
765    }
766
767    #[test]
768    fn no_args_prints_usage() {
769        let out = run(&[]).unwrap();
770        assert!(out.contains("USAGE"));
771    }
772
773    #[test]
774    fn help_prints_usage() {
775        let out = run(&args(&["help"])).unwrap();
776        assert!(out.contains("USAGE"));
777    }
778
779    #[test]
780    fn unknown_command_errors() {
781        let err = run(&args(&["nope"])).unwrap_err();
782        assert!(err.to_string().contains("unknown command"));
783    }
784
785    #[test]
786    fn sctid_reports_structure() {
787        let out = run(&args(&["sctid", "138875005"])).unwrap();
788        assert!(out.contains("component type: Concept"));
789        assert!(out.contains("short (International)"));
790    }
791
792    #[test]
793    fn sctid_rejects_malformed_input() {
794        let err = run(&args(&["sctid", "not-an-id"])).unwrap_err();
795        assert!(!err.to_string().is_empty());
796    }
797
798    #[test]
799    fn load_missing_dir_errors() {
800        let err = run(&args(&["load"])).unwrap_err();
801        assert!(err.to_string().contains("usage"));
802    }
803}