1#![forbid(unsafe_code)]
22mod 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
52pub 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
261fn 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
328fn 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
355fn 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
439fn 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
528fn 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
543fn 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
576fn 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
637fn 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}