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