1mod 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
40pub 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
249fn 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
316fn 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
343fn 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
427fn 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
516fn 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
531fn 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
564fn 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
625fn 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}