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, DescriptionTypeRefsetMember,
29 ExtendedMapRefsetMember, LanguageRefsetMember, ModuleDependencyRefsetMember,
30 OwlExpressionRefsetMember, RefsetDescriptorRefsetMember, SimpleMapRefsetMember,
31 SimpleRefsetMember,
32};
33use snomed_rf2::release_type::ReleaseType;
34use snomed_store::{SnapshotStore, SnapshotStoreBuilder};
35
36use snomed_owl::Axiom;
37
38pub fn run(args: &[String]) -> Result<String, Box<dyn Error>> {
42 let Some((cmd, rest)) = args.split_first() else {
43 return Ok(usage());
44 };
45 match cmd.as_str() {
46 "sctid" => cmd_sctid(rest),
47 "load" => cmd_load(rest),
48 "lookup" => cmd_lookup(rest),
49 "ecl" => cmd_ecl(rest),
50 "export" => cmd_export(rest),
51 "validate" => cmd_validate(rest),
52 "classify" => cmd_classify(rest),
53 "help" | "-h" | "--help" => Ok(usage()),
54 other => Err(format!("unknown command `{other}` (try `snomed-cli help`)").into()),
55 }
56}
57
58fn usage() -> String {
59 let rows: &[(&str, &str)] = &[
60 ("sctid <id>", "validate an SCTID and show its structure"),
61 (
62 "load <release-dir> [--full]",
63 "load a release directory, print a summary",
64 ),
65 (
66 "lookup <release-dir> <id>",
67 "look up a concept: FSN, synonyms, parents, children",
68 ),
69 (
70 "ecl <release-dir> <expression>",
71 "evaluate an ECL expression (quote it)",
72 ),
73 (
74 "export <rf2-file> [output-file]",
75 "convert one RF2 file to NDJSON (stdout if no output file)",
76 ),
77 (
78 "export <release-dir> <output-dir> [--full]",
79 "convert every exportable file in a release directory to NDJSON",
80 ),
81 (
82 "validate <release-dir> [--full]",
83 "check referential integrity and IS-A acyclicity",
84 ),
85 (
86 "classify <release-dir> [concept-id] [--full]",
87 "classify the release's OWL axioms; show one concept's entailed supertypes, or a summary",
88 ),
89 ];
90 let width = rows.iter().map(|(cmd, _)| cmd.len()).max().unwrap_or(0);
91
92 let mut out = String::new();
93 let _ = writeln!(out, "snomed-cli — local SNOMED CT RF2 toolkit\n");
94 let _ = writeln!(out, "USAGE:");
95 for (cmd, desc) in rows {
96 let _ = writeln!(out, " snomed-cli {cmd:width$} {desc}");
97 }
98 let _ = writeln!(
99 out,
100 "\n<release-dir> is an unzipped RF2 release directory. `load`/`lookup`/`ecl`\n\
101 read its Snapshot view by default; `load --full` reads the Full view."
102 );
103 out
104}
105
106fn cmd_sctid(args: &[String]) -> Result<String, Box<dyn Error>> {
107 let raw = args.first().ok_or("usage: sctid <id>")?;
108 let id = SctId::parse(raw)?;
109
110 let mut out = String::new();
111 writeln!(out, "{id}")?;
112 writeln!(
113 out,
114 " component type: {}",
115 id.component_type()
116 .map(|c| c.to_string())
117 .unwrap_or_else(|| "unknown".to_string())
118 )?;
119 writeln!(
120 out,
121 " format: {}",
122 if id.is_long_format() {
123 "long (extension)"
124 } else {
125 "short (International)"
126 }
127 )?;
128 writeln!(out, " partition: {:02}", id.partition())?;
129 if let Some(ns) = id.namespace() {
130 writeln!(out, " namespace: {ns:07}")?;
131 }
132 writeln!(out, " item id: {}", id.item_identifier())?;
133 writeln!(out, " check digit: {}", id.check_digit())?;
134 Ok(out)
135}
136
137fn parse_load_args<'a>(
138 args: &'a [String],
139 usage_msg: &'static str,
140) -> Result<(&'a str, ReleaseType), Box<dyn Error>> {
141 let mut dir = None;
142 let mut release_type = ReleaseType::Snapshot;
143 for a in args {
144 match a.as_str() {
145 "--full" => release_type = ReleaseType::Full,
146 other if dir.is_none() => dir = Some(other),
147 other => {
148 return Err(format!("unexpected argument `{other}`\nusage: {usage_msg}").into())
149 }
150 }
151 }
152 let dir = dir.ok_or_else(|| format!("usage: {usage_msg}"))?;
153 Ok((dir, release_type))
154}
155
156fn load(dir: &str, release_type: ReleaseType) -> Result<(SnapshotStore, String), Box<dyn Error>> {
157 let start = Instant::now();
158 let mut builder = SnapshotStoreBuilder::new();
159 let report = builder.load_release_dir(Path::new(dir), release_type)?;
160 let elapsed = start.elapsed();
161
162 let mut out = String::new();
163 writeln!(
164 out,
165 "loaded {} file(s), skipped {} in {elapsed:.2?}",
166 report.loaded.len(),
167 report.skipped.len()
168 )?;
169 for (path, reason) in &report.skipped {
170 writeln!(out, " skipped {}: {reason}", path.display())?;
171 }
172 let store = builder.build();
173 Ok((store, out))
174}
175
176fn cmd_load(args: &[String]) -> Result<String, Box<dyn Error>> {
177 let (dir, release_type) = parse_load_args(args, "load <release-dir> [--full]")?;
178 let (store, mut out) = load(dir, release_type)?;
179 writeln!(
180 out,
181 "concepts: {} ({} active)",
182 store.concept_count(),
183 store.active_concepts().count()
184 )?;
185 Ok(out)
186}
187
188fn cmd_validate(args: &[String]) -> Result<String, Box<dyn Error>> {
189 let (dir, release_type) = parse_load_args(args, "validate <release-dir> [--full]")?;
190 let (store, mut out) = load(dir, release_type)?;
191 let report = store.validate();
192
193 if report.is_clean() {
194 writeln!(
195 out,
196 "no issues found ({} concepts checked)",
197 store.concept_count()
198 )?;
199 return Ok(out);
200 }
201
202 writeln!(out, "{} issue(s) found:", report.issue_count())?;
203 write_ids(
204 &mut out,
205 "dangling description concept references",
206 &report.dangling_description_concepts,
207 )?;
208 write_ids(
209 &mut out,
210 "dangling relationship source references",
211 &report.dangling_relationship_sources,
212 )?;
213 write_ids(
214 &mut out,
215 "dangling relationship destination references",
216 &report.dangling_relationship_destinations,
217 )?;
218 write_ids(
219 &mut out,
220 "concepts on a cyclic IS-A path",
221 &report.cyclic_concepts,
222 )?;
223 Ok(out)
224}
225
226fn write_ids(out: &mut String, label: &str, ids: &[SctId]) -> Result<(), Box<dyn Error>> {
227 if ids.is_empty() {
228 return Ok(());
229 }
230 writeln!(out, " {label} ({}):", ids.len())?;
231 for id in ids {
232 writeln!(out, " {id}")?;
233 }
234 Ok(())
235}
236
237fn cmd_classify(args: &[String]) -> Result<String, Box<dyn Error>> {
245 let usage = "usage: classify <release-dir> [concept-id] [--full]";
246 let mut positional: Vec<&str> = Vec::new();
247 let mut release_type = ReleaseType::Snapshot;
248 for a in args {
249 match a.as_str() {
250 "--full" => release_type = ReleaseType::Full,
251 other => positional.push(other),
252 }
253 }
254 let (dir, concept_id) = match positional.as_slice() {
255 [dir] => (*dir, None),
256 [dir, id] => (*dir, Some(*id)),
257 _ => return Err(usage.into()),
258 };
259
260 let (store, mut out) = load(dir, release_type)?;
261
262 let mut axioms: Vec<Axiom> = Vec::new();
263 let mut parse_failures: Vec<(SctId, String)> = Vec::new();
264 for member in store.all_owl_expression_members() {
265 match snomed_owl::parse(&member.owl_expression) {
266 Ok(axiom) => axioms.push(axiom),
267 Err(e) => parse_failures.push((member.core.referenced_component_id, e.to_string())),
268 }
269 }
270 writeln!(
271 out,
272 "OWL axioms: {} parsed, {} failed to parse",
273 axioms.len(),
274 parse_failures.len()
275 )?;
276 write_capped(&mut out, &parse_failures, |out, (id, reason)| {
277 writeln!(out, " parse error on {id}: {reason}")
278 })?;
279
280 let report = snomed_classify::classify(&axioms);
281 if !report.skipped.is_empty() {
282 writeln!(
283 out,
284 "{} construct(s) not modeled during classification:",
285 report.skipped.len()
286 )?;
287 write_capped(&mut out, &report.skipped, |out, s| writeln!(out, " {s}"))?;
288 }
289
290 match concept_id {
291 Some(id_str) => {
292 let id = SctId::parse(id_str)?;
293 let mut supers: Vec<SctId> = report.classification.subsumers(id).collect();
294 supers.sort();
295 writeln!(
296 out,
297 "{id} is entailed to be subsumed by {} concept(s):",
298 supers.len()
299 )?;
300 for s in supers {
301 let name = store.fsn(s).map(|d| d.term.as_str()).unwrap_or("?");
302 writeln!(out, " {s} {name}")?;
303 }
304 }
305 None => {
306 let concepts: Vec<SctId> = report.classification.concepts().collect();
307 let total_pairs: usize = concepts
308 .iter()
309 .map(|&c| report.classification.subsumers(c).count())
310 .sum();
311 writeln!(
312 out,
313 "{} concept(s) classified, {total_pairs} entailed subsumption pair(s) total",
314 concepts.len()
315 )?;
316 }
317 }
318 Ok(out)
319}
320
321fn write_capped<T>(
326 out: &mut String,
327 items: &[T],
328 mut write_one: impl FnMut(&mut String, &T) -> std::fmt::Result,
329) -> Result<(), Box<dyn Error>> {
330 const CAP: usize = 5;
331 for item in items.iter().take(CAP) {
332 write_one(out, item)?;
333 }
334 if items.len() > CAP {
335 writeln!(out, " ... and {} more", items.len() - CAP)?;
336 }
337 Ok(())
338}
339
340fn cmd_lookup(args: &[String]) -> Result<String, Box<dyn Error>> {
341 let (dir, id_raw) = match args {
342 [dir, id] => (dir.as_str(), id.as_str()),
343 _ => return Err("usage: lookup <release-dir> <id>".into()),
344 };
345 let id = SctId::parse(id_raw)?;
346 let (store, _) = load(dir, ReleaseType::Snapshot)?;
347
348 let mut out = String::new();
349 let Some(concept) = store.concept(id) else {
350 writeln!(out, "{id}: not found in this snapshot")?;
351 return Ok(out);
352 };
353 writeln!(
354 out,
355 "{id} active={} module={}",
356 concept.active, concept.module_id
357 )?;
358 if let Some(fsn) = store.fsn(id) {
359 writeln!(out, " FSN: {}", fsn.term)?;
360 }
361 for syn in store
362 .descriptions_of(id)
363 .filter(|d| d.active && d.is_synonym())
364 {
365 writeln!(out, " synonym: {}", syn.term)?;
366 }
367 write_related(&mut out, "parents", store.parents(id), &store)?;
368 write_related(&mut out, "children", store.children(id), &store)?;
369 Ok(out)
370}
371
372fn write_related(
373 out: &mut String,
374 label: &str,
375 ids: &[SctId],
376 store: &SnapshotStore,
377) -> Result<(), Box<dyn Error>> {
378 if ids.is_empty() {
379 return Ok(());
380 }
381 writeln!(out, " {label}:")?;
382 for &id in ids {
383 let name = store.fsn(id).map(|d| d.term.as_str()).unwrap_or("?");
384 writeln!(out, " {id} {name}")?;
385 }
386 Ok(())
387}
388
389fn cmd_ecl(args: &[String]) -> Result<String, Box<dyn Error>> {
390 let (dir, expr_str) = match args {
391 [dir, expr] => (dir.as_str(), expr.as_str()),
392 _ => return Err("usage: ecl <release-dir> <expression> (quote the expression)".into()),
393 };
394 let (store, _) = load(dir, ReleaseType::Snapshot)?;
395
396 let expr = snomed_ecl::parse(expr_str)?;
397 let matches = snomed_ecl::evaluate(&expr, &store);
398 let mut sorted: Vec<SctId> = matches.into_iter().collect();
399 sorted.sort();
400
401 let mut out = String::new();
402 writeln!(out, "{} match(es)", sorted.len())?;
403 for id in sorted {
404 let name = store.fsn(id).map(|d| d.term.as_str()).unwrap_or("?");
405 writeln!(out, "{id} {name}")?;
406 }
407 Ok(out)
408}
409
410fn cmd_export(args: &[String]) -> Result<String, Box<dyn Error>> {
415 let usage =
416 "usage: export <rf2-file> [output-file] | export <release-dir> <output-dir> [--full]";
417 let first = args.first().ok_or(usage)?;
418 if Path::new(first).is_dir() {
419 cmd_export_dir(args)
420 } else {
421 cmd_export_file(args)
422 }
423}
424
425fn cmd_export_file(args: &[String]) -> Result<String, Box<dyn Error>> {
429 let (input, output) = match args {
430 [input] => (input.as_str(), None),
431 [input, output] => (input.as_str(), Some(output.as_str())),
432 _ => return Err("usage: export <rf2-file> [output-file]".into()),
433 };
434
435 let path = Path::new(input);
436 let file_name = path
437 .file_name()
438 .and_then(|n| n.to_str())
439 .ok_or("input file name is not valid UTF-8")?;
440 let parsed = ReleaseFileName::parse(file_name)?;
441 let ndjson = export_to_ndjson(path, &parsed)?.ok_or_else(|| {
442 format!(
443 "content type `{}` (summary `{}`) is not yet exportable",
444 parsed.content_type, parsed.summary
445 )
446 })?;
447
448 match output {
449 Some(out_path) => {
450 let line_count = ndjson.lines().count();
451 fs::write(out_path, &ndjson)?;
452 Ok(format!("wrote {line_count} line(s) to {out_path}\n"))
453 }
454 None => Ok(ndjson),
455 }
456}
457
458fn cmd_export_dir(args: &[String]) -> Result<String, Box<dyn Error>> {
468 let usage = "usage: export <release-dir> <output-dir> [--full]";
469 let mut positional = Vec::new();
470 let mut release_type = ReleaseType::Snapshot;
471 for a in args {
472 match a.as_str() {
473 "--full" => release_type = ReleaseType::Full,
474 other => positional.push(other),
475 }
476 }
477 let (dir, out_dir) = match positional.as_slice() {
478 [dir, out_dir] => (*dir, *out_dir),
479 _ => return Err(usage.into()),
480 };
481
482 let files = snomed_store::list_release_files(Path::new(dir), release_type)?;
483 fs::create_dir_all(out_dir)?;
484
485 let mut exported = 0usize;
486 let mut skipped: Vec<(std::path::PathBuf, String)> = Vec::new();
487 for (path, parsed) in &files {
488 match export_to_ndjson(path, parsed)? {
489 Some(ndjson) => {
490 let stem = path
491 .file_stem()
492 .and_then(|s| s.to_str())
493 .ok_or("input file name is not valid UTF-8")?;
494 fs::write(Path::new(out_dir).join(format!("{stem}.ndjson")), &ndjson)?;
495 exported += 1;
496 }
497 None => skipped.push((
498 path.clone(),
499 format!(
500 "content type `{}` (summary `{}`) is not yet exportable",
501 parsed.content_type, parsed.summary
502 ),
503 )),
504 }
505 }
506
507 let mut out = String::new();
508 writeln!(
509 out,
510 "exported {exported} file(s), skipped {} to {out_dir}",
511 skipped.len()
512 )?;
513 for (path, reason) in &skipped {
514 writeln!(out, " skipped {}: {reason}", path.display())?;
515 }
516 Ok(out)
517}
518
519fn export_to_ndjson(path: &Path, f: &ReleaseFileName) -> Result<Option<String>, Box<dyn Error>> {
524 let mut out = String::new();
525 match (f.content_type.as_str(), f.summary.as_str()) {
526 ("Concept", _) => export_rows::<Concept, _>(path, &mut out, json::concept_to_json)?,
527 ("Description", _) | ("TextDefinition", _) => {
528 export_rows::<Description, _>(path, &mut out, json::description_to_json)?
529 }
530 ("Relationship", _) | ("StatedRelationship", _) => {
531 export_rows::<Relationship, _>(path, &mut out, json::relationship_to_json)?
532 }
533 ("RelationshipConcreteValues", _) => export_rows::<RelationshipConcreteValue, _>(
534 path,
535 &mut out,
536 json::relationship_concrete_value_to_json,
537 )?,
538 ("Refset", _) => {
539 export_rows::<SimpleRefsetMember, _>(path, &mut out, json::simple_refset_to_json)?
540 }
541 ("cRefset", "Language") => {
542 export_rows::<LanguageRefsetMember, _>(path, &mut out, json::language_refset_to_json)?
543 }
544 ("cRefset", summary) if summary.contains("Association") => {
545 export_rows::<AssociationRefsetMember, _>(
546 path,
547 &mut out,
548 json::association_refset_to_json,
549 )?
550 }
551 ("cRefset", summary) if summary.contains("AttributeValue") => {
552 export_rows::<AttributeValueRefsetMember, _>(
553 path,
554 &mut out,
555 json::attribute_value_refset_to_json,
556 )?
557 }
558 ("sRefset", "SimpleMap") => export_rows::<SimpleMapRefsetMember, _>(
559 path,
560 &mut out,
561 json::simple_map_refset_to_json,
562 )?,
563 ("sRefset", "OWLExpression") => export_rows::<OwlExpressionRefsetMember, _>(
564 path,
565 &mut out,
566 json::owl_expression_refset_to_json,
567 )?,
568 ("iisssccRefset", _) => export_rows::<ExtendedMapRefsetMember, _>(
569 path,
570 &mut out,
571 json::extended_map_refset_to_json,
572 )?,
573 ("ssRefset", "ModuleDependency") => export_rows::<ModuleDependencyRefsetMember, _>(
574 path,
575 &mut out,
576 json::module_dependency_refset_to_json,
577 )?,
578 ("cciRefset", "RefsetDescriptor") => export_rows::<RefsetDescriptorRefsetMember, _>(
579 path,
580 &mut out,
581 json::refset_descriptor_refset_to_json,
582 )?,
583 ("ciRefset", "DescriptionType") => export_rows::<DescriptionTypeRefsetMember, _>(
584 path,
585 &mut out,
586 json::description_type_refset_to_json,
587 )?,
588 (_, _) => return Ok(None),
589 }
590 Ok(Some(out))
591}
592
593fn export_rows<T, F>(path: &Path, out: &mut String, to_json: F) -> Result<(), Box<dyn Error>>
594where
595 T: Rf2Record,
596 F: Fn(&T) -> String,
597{
598 let file = File::open(path)?;
599 let reader = Rf2Reader::<_, T>::new(BufReader::new(file))?;
600 for row in reader {
601 out.push_str(&to_json(&row?));
602 out.push('\n');
603 }
604 Ok(())
605}
606
607#[cfg(test)]
608mod tests {
609 use super::*;
610
611 fn args(strs: &[&str]) -> Vec<String> {
612 strs.iter().map(|s| s.to_string()).collect()
613 }
614
615 #[test]
616 fn no_args_prints_usage() {
617 let out = run(&[]).unwrap();
618 assert!(out.contains("USAGE"));
619 }
620
621 #[test]
622 fn help_prints_usage() {
623 let out = run(&args(&["help"])).unwrap();
624 assert!(out.contains("USAGE"));
625 }
626
627 #[test]
628 fn unknown_command_errors() {
629 let err = run(&args(&["nope"])).unwrap_err();
630 assert!(err.to_string().contains("unknown command"));
631 }
632
633 #[test]
634 fn sctid_reports_structure() {
635 let out = run(&args(&["sctid", "138875005"])).unwrap();
636 assert!(out.contains("component type: Concept"));
637 assert!(out.contains("short (International)"));
638 }
639
640 #[test]
641 fn sctid_rejects_malformed_input() {
642 let err = run(&args(&["sctid", "not-an-id"])).unwrap_err();
643 assert!(!err.to_string().is_empty());
644 }
645
646 #[test]
647 fn load_missing_dir_errors() {
648 let err = run(&args(&["load"])).unwrap_err();
649 assert!(err.to_string().contains("usage"));
650 }
651}