Skip to main content

snomed_cli/
lib.rs

1//! Command-line toolkit over the `snomed` workspace crates: SCTID
2//! validation, release loading, concept lookup, ECL queries.
3//!
4//! This crate is deliberately a thin presentation layer — every subcommand
5//! is a few lines of formatting around calls into `snomed-core`,
6//! `snomed-rf2`, `snomed-store`, and `snomed-ecl`. New domain logic belongs
7//! in those crates, not here (see `AGENTS/cli-engineer.md`).
8//!
9//! [`run`] is the single entry point, returning the formatted output as a
10//! `String` (rather than printing directly) so subcommands are unit- and
11//! integration-testable without spawning the compiled binary.
12
13use 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
22/// Dispatches on `args[0]` (the subcommand name) and returns the formatted
23/// output. `args` excludes the program name (pass `std::env::args().skip(1)`
24/// collected into a `Vec`, or an equivalent slice in tests).
25pub 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}