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