1use std::error::Error;
14use std::fmt::Write as _;
15use std::path::Path;
16use std::time::Instant;
17
18use snomed_core::sctid::SctId;
19use snomed_rf2::release_type::ReleaseType;
20use snomed_store::{SnapshotStore, SnapshotStoreBuilder};
21
22pub fn run(args: &[String]) -> Result<String, Box<dyn Error>> {
26 let Some((cmd, rest)) = args.split_first() else {
27 return Ok(usage());
28 };
29 match cmd.as_str() {
30 "sctid" => cmd_sctid(rest),
31 "load" => cmd_load(rest),
32 "lookup" => cmd_lookup(rest),
33 "ecl" => cmd_ecl(rest),
34 "help" | "-h" | "--help" => Ok(usage()),
35 other => Err(format!("unknown command `{other}` (try `snomed-cli help`)").into()),
36 }
37}
38
39fn usage() -> String {
40 let rows: &[(&str, &str)] = &[
41 ("sctid <id>", "validate an SCTID and show its structure"),
42 (
43 "load <release-dir> [--full]",
44 "load a release directory, print a summary",
45 ),
46 (
47 "lookup <release-dir> <id>",
48 "look up a concept: FSN, synonyms, parents, children",
49 ),
50 (
51 "ecl <release-dir> <expression>",
52 "evaluate an ECL expression (quote it)",
53 ),
54 ];
55 let width = rows.iter().map(|(cmd, _)| cmd.len()).max().unwrap_or(0);
56
57 let mut out = String::new();
58 let _ = writeln!(out, "snomed-cli — local SNOMED CT RF2 toolkit\n");
59 let _ = writeln!(out, "USAGE:");
60 for (cmd, desc) in rows {
61 let _ = writeln!(out, " snomed-cli {cmd:width$} {desc}");
62 }
63 let _ = writeln!(
64 out,
65 "\n<release-dir> is an unzipped RF2 release directory. `load`/`lookup`/`ecl`\n\
66 read its Snapshot view by default; `load --full` reads the Full view."
67 );
68 out
69}
70
71fn cmd_sctid(args: &[String]) -> Result<String, Box<dyn Error>> {
72 let raw = args.first().ok_or("usage: sctid <id>")?;
73 let id = SctId::parse(raw)?;
74
75 let mut out = String::new();
76 writeln!(out, "{id}")?;
77 writeln!(
78 out,
79 " component type: {}",
80 id.component_type()
81 .map(|c| c.to_string())
82 .unwrap_or_else(|| "unknown".to_string())
83 )?;
84 writeln!(
85 out,
86 " format: {}",
87 if id.is_long_format() {
88 "long (extension)"
89 } else {
90 "short (International)"
91 }
92 )?;
93 writeln!(out, " partition: {:02}", id.partition())?;
94 if let Some(ns) = id.namespace() {
95 writeln!(out, " namespace: {ns:07}")?;
96 }
97 writeln!(out, " item id: {}", id.item_identifier())?;
98 writeln!(out, " check digit: {}", id.check_digit())?;
99 Ok(out)
100}
101
102fn parse_load_args<'a>(
103 args: &'a [String],
104 usage_msg: &'static str,
105) -> Result<(&'a str, ReleaseType), Box<dyn Error>> {
106 let mut dir = None;
107 let mut release_type = ReleaseType::Snapshot;
108 for a in args {
109 match a.as_str() {
110 "--full" => release_type = ReleaseType::Full,
111 other if dir.is_none() => dir = Some(other),
112 other => {
113 return Err(format!("unexpected argument `{other}`\nusage: {usage_msg}").into())
114 }
115 }
116 }
117 let dir = dir.ok_or_else(|| format!("usage: {usage_msg}"))?;
118 Ok((dir, release_type))
119}
120
121fn load(dir: &str, release_type: ReleaseType) -> Result<(SnapshotStore, String), Box<dyn Error>> {
122 let start = Instant::now();
123 let mut builder = SnapshotStoreBuilder::new();
124 let report = builder.load_release_dir(Path::new(dir), release_type)?;
125 let elapsed = start.elapsed();
126
127 let mut out = String::new();
128 writeln!(
129 out,
130 "loaded {} file(s), skipped {} in {elapsed:.2?}",
131 report.loaded.len(),
132 report.skipped.len()
133 )?;
134 for (path, reason) in &report.skipped {
135 writeln!(out, " skipped {}: {reason}", path.display())?;
136 }
137 let store = builder.build();
138 Ok((store, out))
139}
140
141fn cmd_load(args: &[String]) -> Result<String, Box<dyn Error>> {
142 let (dir, release_type) = parse_load_args(args, "load <release-dir> [--full]")?;
143 let (store, mut out) = load(dir, release_type)?;
144 writeln!(
145 out,
146 "concepts: {} ({} active)",
147 store.concept_count(),
148 store.active_concepts().count()
149 )?;
150 Ok(out)
151}
152
153fn cmd_lookup(args: &[String]) -> Result<String, Box<dyn Error>> {
154 let (dir, id_raw) = match args {
155 [dir, id] => (dir.as_str(), id.as_str()),
156 _ => return Err("usage: lookup <release-dir> <id>".into()),
157 };
158 let id = SctId::parse(id_raw)?;
159 let (store, _) = load(dir, ReleaseType::Snapshot)?;
160
161 let mut out = String::new();
162 let Some(concept) = store.concept(id) else {
163 writeln!(out, "{id}: not found in this snapshot")?;
164 return Ok(out);
165 };
166 writeln!(
167 out,
168 "{id} active={} module={}",
169 concept.active, concept.module_id
170 )?;
171 if let Some(fsn) = store.fsn(id) {
172 writeln!(out, " FSN: {}", fsn.term)?;
173 }
174 for syn in store
175 .descriptions_of(id)
176 .filter(|d| d.active && d.is_synonym())
177 {
178 writeln!(out, " synonym: {}", syn.term)?;
179 }
180 write_related(&mut out, "parents", store.parents(id), &store)?;
181 write_related(&mut out, "children", store.children(id), &store)?;
182 Ok(out)
183}
184
185fn write_related(
186 out: &mut String,
187 label: &str,
188 ids: &[SctId],
189 store: &SnapshotStore,
190) -> Result<(), Box<dyn Error>> {
191 if ids.is_empty() {
192 return Ok(());
193 }
194 writeln!(out, " {label}:")?;
195 for &id in ids {
196 let name = store.fsn(id).map(|d| d.term.as_str()).unwrap_or("?");
197 writeln!(out, " {id} {name}")?;
198 }
199 Ok(())
200}
201
202fn cmd_ecl(args: &[String]) -> Result<String, Box<dyn Error>> {
203 let (dir, expr_str) = match args {
204 [dir, expr] => (dir.as_str(), expr.as_str()),
205 _ => return Err("usage: ecl <release-dir> <expression> (quote the expression)".into()),
206 };
207 let (store, _) = load(dir, ReleaseType::Snapshot)?;
208
209 let expr = snomed_ecl::parse(expr_str)?;
210 let matches = snomed_ecl::evaluate(&expr, &store);
211 let mut sorted: Vec<SctId> = matches.into_iter().collect();
212 sorted.sort();
213
214 let mut out = String::new();
215 writeln!(out, "{} match(es)", sorted.len())?;
216 for id in sorted {
217 let name = store.fsn(id).map(|d| d.term.as_str()).unwrap_or("?");
218 writeln!(out, "{id} {name}")?;
219 }
220 Ok(out)
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 fn args(strs: &[&str]) -> Vec<String> {
228 strs.iter().map(|s| s.to_string()).collect()
229 }
230
231 #[test]
232 fn no_args_prints_usage() {
233 let out = run(&[]).unwrap();
234 assert!(out.contains("USAGE"));
235 }
236
237 #[test]
238 fn help_prints_usage() {
239 let out = run(&args(&["help"])).unwrap();
240 assert!(out.contains("USAGE"));
241 }
242
243 #[test]
244 fn unknown_command_errors() {
245 let err = run(&args(&["nope"])).unwrap_err();
246 assert!(err.to_string().contains("unknown command"));
247 }
248
249 #[test]
250 fn sctid_reports_structure() {
251 let out = run(&args(&["sctid", "138875005"])).unwrap();
252 assert!(out.contains("component type: Concept"));
253 assert!(out.contains("short (International)"));
254 }
255
256 #[test]
257 fn sctid_rejects_malformed_input() {
258 let err = run(&args(&["sctid", "not-an-id"])).unwrap_err();
259 assert!(!err.to_string().is_empty());
260 }
261
262 #[test]
263 fn load_missing_dir_errors() {
264 let err = run(&args(&["load"])).unwrap_err();
265 assert!(err.to_string().contains("usage"));
266 }
267}