use anyhow::{bail, ensure, Context, Result};
#[cfg(feature = "import")]
use snomed_ecl_engine::import::{import_snapshot_with_progress, ImportOptions, UK_DISPLAY_REFSETS};
use snomed_ecl_engine::store::{DisplayStore, Manifest, NumericStore};
use snomed_ecl_engine::{ecl, eval};
use std::io::{self, BufRead, Read, Write};
use std::path::{Path, PathBuf};
use std::time::Instant;
#[cfg(feature = "download")]
mod download;
mod library;
mod presentation;
mod workspace;
fn parse(expression: &str) -> Result<ecl::Expr> {
ecl::parse(expression).map_err(|error| {
anyhow::anyhow!(
"{error}\n{}\nECL byte offset: {}. See docs/ecl-support.md for current support.",
presentation::caret(expression, error.offset),
error.offset
)
})
}
fn main() {
if let Err(error) = run() {
if error
.downcast_ref::<io::Error>()
.is_some_and(|e| e.kind() == io::ErrorKind::BrokenPipe)
{
return;
}
eprintln!(
"Error: {}",
presentation::clean_message(&format!("{error:#}"))
);
std::process::exit(1);
}
}
fn take_flag(args: &mut Vec<String>, flag: &str) -> bool {
let before = args.len();
args.retain(|arg| arg != flag);
args.len() != before
}
fn take_option(args: &mut Vec<String>, option: &str) -> Result<Option<String>> {
let Some(position) = args.iter().position(|arg| arg == option) else {
return Ok(None);
};
ensure!(position + 1 < args.len(), "{option} needs a value");
let value = args.remove(position + 1);
args.remove(position);
ensure!(
!args.iter().any(|arg| arg == option),
"{option} may only be given once"
);
Ok(Some(value))
}
fn open_store(
path: &Path,
config: &Option<snomed_ecl_engine::config::QueryConfig>,
) -> Result<NumericStore> {
let mut store = NumericStore::open(path)?;
if let Some(config) = config {
store.config = config.clone();
}
Ok(store)
}
fn show(answer: Vec<u8>, human: bool, render: impl FnOnce(&serde_json::Value)) -> Result<()> {
let value: serde_json::Value = serde_json::from_slice(&answer)?;
if let Some(error) = value.get("error") {
if error == "NotFound" {
bail!(
"No concept {} in this index",
value["concept"].as_str().unwrap_or("with that code")
);
}
let detail = value
.get("message")
.or_else(|| value.get("concept"))
.map(|detail| {
detail
.as_str()
.map_or_else(|| detail.to_string(), str::to_owned)
})
.unwrap_or_default();
bail!("{} {detail}", error.as_str().unwrap_or("Error"));
}
if human {
render(&value);
} else {
io::stdout().write_all(&answer)?;
}
Ok(())
}
fn confirm(question: &str, otherwise: &str) -> Result<bool> {
use std::io::IsTerminal;
ensure!(
io::stdin().is_terminal() && io::stderr().is_terminal(),
"{otherwise}"
);
eprint!("{question} [y/N] ");
io::stderr().flush()?;
let mut answer = String::new();
io::stdin().lock().read_line(&mut answer)?;
Ok(matches!(
answer.trim().to_ascii_lowercase().as_str(),
"y" | "yes"
))
}
#[cfg(feature = "import")]
fn add_release(
archive: &Path,
sha256: Option<String>,
name: Option<String>,
edition: Option<String>,
human: bool,
) -> Result<()> {
let summary = snomed_ecl_engine::import::inspect_archive(archive)?;
ensure!(
summary.importable,
"{} is not a self-contained RF2 Snapshot; `inspect` shows what is missing",
archive.display()
);
let edition = match edition {
Some(uri) => uri,
None if summary.root_editions == 1 => summary.edition_uris[0].clone(),
None => bail!(
"The archive does not name one edition; choose one with --edition URI. \
`inspect` lists the candidates"
),
};
match sha256 {
Some(expected) => ensure!(
expected.trim().eq_ignore_ascii_case(&summary.sha256),
"The archive's SHA-256 is {}, not the {} given. Check the download",
summary.sha256,
expected.trim()
),
None => {
eprintln!(" SHA-256 {}", summary.sha256);
let confirmed = confirm(
" Does this match the checksum your distributor published?",
"Give the distributor's published checksum with --sha256; \
`inspect` prints this archive's",
)?;
ensure!(
confirmed,
"Not added. Check the archive against the distributor's checksum"
);
}
}
let name = match name {
Some(name) => name,
None => library::default_name(&edition)
.context("This edition URI has no release date; name the index with --name")?,
};
library::check_name(&name)?;
let home = library::home()?;
std::fs::create_dir_all(&home)
.with_context(|| format!("Cannot create the library folder {}", home.display()))?;
let destination = home.join(format!("{name}.ecl"));
ensure!(
!destination.exists(),
"The library already has {name}. Remove it with `remove {name}`, or choose another --name"
);
let staging = home.join(format!(".{name}.building-{}", std::process::id()));
let stages = snomed_ecl_engine::import::IMPORT_STAGES + 1;
let start = Instant::now();
let mut stage = 0;
let built = import_snapshot_with_progress(
archive,
&staging,
&ImportOptions {
edition: edition.clone(),
expected_sha256: summary.sha256.clone(),
display_refsets: UK_DISPLAY_REFSETS.to_vec(),
},
|message| {
stage += 1;
eprintln!(
" [{stage}/{stages}] {message} ({:.1}s elapsed)",
start.elapsed().as_secs_f64()
);
},
)
.and_then(|_| {
eprintln!(
" [{stages}/{stages}] Packing into one file ({:.1}s elapsed)",
start.elapsed().as_secs_f64()
);
snomed_ecl_engine::store::pack(&staging, &destination)
});
let _ = std::fs::remove_dir_all(&staging);
built?;
let path = std::fs::canonicalize(&destination)?;
workspace::save(&workspace::State {
store: Some(path.clone()),
})?;
let bytes = std::fs::metadata(&path)?.len();
if human {
println!(
"\n Added {name}, {}, in {:.0}s. It is now selected:",
presentation::bytes(bytes),
start.elapsed().as_secs_f64()
);
println!("\n snomed-ecl-engine query");
println!(" snomed-ecl-engine expand '<< 73211009 |Diabetes mellitus|' --display");
} else {
println!(
"{}",
serde_json::json!({
"name": name,
"store": path,
"edition": edition,
"bytes": bytes,
"elapsed_seconds": start.elapsed().as_secs_f64(),
})
);
}
Ok(())
}
fn remove_index(reference: &str, yes: bool) -> Result<()> {
let path = library::find(reference)?.with_context(|| {
format!("The library has no index {reference}; `list` shows the indexes it has")
})?;
let name = path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or(reference)
.to_owned();
if !yes {
let bytes = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
let question = format!("Remove {name} ({})?", presentation::bytes(bytes));
let confirmed = confirm(
&question,
&format!("Add --yes to remove {name} without a prompt"),
)?;
ensure!(confirmed, "Not removed");
}
std::fs::remove_file(&path).with_context(|| format!("Cannot remove {}", path.display()))?;
let selected = workspace::load().store;
if selected
.is_some_and(|selected| std::fs::canonicalize(&selected).is_err() || selected == path)
{
workspace::save(&workspace::State::default())?;
}
println!("Removed {name}.");
Ok(())
}
fn run() -> Result<()> {
let mut args: Vec<_> = std::env::args().skip(1).collect();
let mut query_config = None;
if let Some(position) = args.iter().position(|s| s == "--config") {
ensure!(
position + 1 < args.len(),
"--config requires a JSON file path"
);
query_config = Some(snomed_ecl_engine::config::QueryConfig::read(Path::new(
&args[position + 1],
))?);
args.drain(position..=position + 1);
ensure!(
!args.iter().any(|s| s == "--config"),
"--config may only be supplied once"
);
}
let json = args.iter().any(|s| s == "--json");
let plain = args.iter().any(|s| s == "--plain");
ensure!(!(json && plain), "Choose either --json or --plain");
args.retain(|s| s != "--json" && s != "--plain");
let human = presentation::human() && !json && !plain;
if args.is_empty()
|| matches!(
args.first().map(String::as_str),
Some("--help" | "-h" | "help")
)
{
return presentation::help(args.get(1).map(String::as_str));
}
if args.len() == 2 && matches!(args[1].as_str(), "--help" | "-h") {
return presentation::help(Some(&args[0]));
}
if args.len() == 1 && matches!(args[0].as_str(), "--version" | "-V") {
println!("snomed-ecl-engine {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
let command = args.first().cloned().unwrap_or_default();
match command.as_str() {
"use" => {
ensure!(args.len() <= 2, "Usage: use [NAME | PATH | --clear]");
let Some(reference) = args.get(1) else {
match workspace::load().store {
Some(path) => println!("Selected: {}", path.display()),
None => println!("No index selected. `list` shows the indexes available."),
}
return Ok(());
};
if reference == "--clear" {
workspace::save(&workspace::State::default())?;
println!("Cleared the selected index.");
return Ok(());
}
let path = std::fs::canonicalize(library::resolve(reference)?)
.with_context(|| format!("No such path: {reference}"))?;
let manifest = Manifest::read(&path).with_context(|| {
format!(
"{} is not an index. `list` shows the indexes available",
path.display()
)
})?;
workspace::save(&workspace::State {
store: Some(path.clone()),
})?;
if human {
let location = format!("{} (now selected)", path.display());
presentation::manifest(&manifest, Some(&location));
} else {
println!(
"{}",
serde_json::json!({"store": path, "edition": manifest.edition})
);
}
}
"list" | "stores" => {
let roots: Vec<_> = if args.len() > 1 {
args[1..].iter().map(PathBuf::from).collect()
} else {
workspace::default_roots()
};
let selected = workspace::load().store;
let found = workspace::discover(&roots, selected.as_deref());
if human {
presentation::stores(&found);
} else {
for entry in &found {
println!(
"{}",
serde_json::json!({
"name": entry.name,
"store": entry.path,
"edition": entry.edition,
"active_concepts": entry.active_concepts,
"concepts": entry.concepts,
"bytes": entry.bytes,
"packed": entry.packed,
"selected": entry.selected,
})
);
}
}
}
#[cfg(not(feature = "import"))]
"inspect" => bail!("Archive inspection needs the importer; rebuild with --features import"),
#[cfg(feature = "import")]
"inspect" => {
ensure!(args.len() == 2, "Usage: inspect ARCHIVE");
let summary = snomed_ecl_engine::import::inspect_archive(Path::new(&args[1]))?;
if !human {
println!("{}", serde_json::to_string_pretty(&summary)?);
return Ok(());
}
println!("{}", presentation::heading("SNOMED ECL / archive"));
println!();
println!(" File {}", presentation::clean(&args[1]));
println!(" Size {}", presentation::bytes(summary.bytes));
println!(" SHA-256 {}", summary.sha256);
println!(
" Release {}",
presentation::clean(&summary.effective_time)
);
println!();
for (label, found) in &summary.required_files {
match found {
Some(name) => println!(" found {}", presentation::clean(name)),
None => println!(" MISSING {label}"),
}
}
if !summary.importable {
println!();
println!(" Required Snapshot files are missing. The importer takes one");
println!(" self-contained Snapshot package; Full, Delta and split");
println!(" extensions are not supported.");
return Ok(());
}
println!();
if summary.edition_uris.is_empty() {
println!(" No module declares this release date, so no edition URI can be");
println!(" offered. Use the versioned URI from the release distributor.");
return Ok(());
}
match summary.root_editions {
1 => println!(" Edition URI"),
0 => println!(" Edition URI candidates (no single root module)"),
n => println!(" Edition URI candidates ({n} root modules)"),
}
for (index, uri) in summary.edition_uris.iter().enumerate() {
let marker = if index < summary.root_editions.max(1) {
" "
} else {
"-"
};
println!(" {marker} {}", presentation::clean(uri));
}
if summary.edition_uris.len() > summary.root_editions.max(1) {
println!();
println!(" Lines marked - are modules another module in this package depends");
println!(" on, so they are components of the edition rather than the edition.");
}
println!();
println!(" Check the SHA-256 above against the value the release distributor");
println!(" published. A checksum taken from the downloaded file shows only that");
println!(" the file is intact, never where it came from. Then import:");
println!();
println!(
" snomed-ecl-engine import {} INDEX_DIRECTORY \\",
presentation::clean(&args[1])
);
println!(" {} \\", presentation::clean(&summary.edition_uris[0]));
println!(" {}", summary.sha256);
}
"query" => {
let mut style = Style::take(&mut args, human, json)?;
ensure!(!style.csv, "query has no CSV output; use expand --csv");
ensure!(args.len() <= 2, "Usage: query [STORE] [--display|--count]");
let (path, source) = workspace::resolve(args.get(1).map(String::as_str))?;
let open_start = Instant::now();
let mut store = NumericStore::open(&path)?;
if let Some(config) = &query_config {
store.config = config.clone();
}
let manifest = Manifest::read(&path)?;
let mut display = None;
let labels = Labels::new(&path);
println!("{}\n", presentation::heading("SNOMED ECL / query"));
println!(" Index {} ({})", path.display(), source.describe());
println!(" Edition {}", presentation::clean(&manifest.edition));
println!(
" Opened {:.2}s, {} active concepts",
open_start.elapsed().as_secs_f64(),
presentation::number(manifest.active_concept_count)
);
println!("\n Type an ECL expression, or :help for commands. :quit exits.\n");
let mut input = io::stdin().lock();
let mut line = String::new();
loop {
print!("ecl> ");
io::stdout().flush()?;
line.clear();
if input.read_line(&mut line)? == 0 {
println!();
break;
}
let text = line.trim();
if text.is_empty() {
continue;
}
match text {
":quit" | ":q" | ":exit" => break,
":help" | ":h" => {
println!(
" :display toggle result terms (currently {})\n \
:count toggle totals only (currently {})\n \
:stats show the index manifest\n \
:search TEXT find concepts by name\n \
:lookup CODE describe one concept\n \
:quit leave\n\n \
Anything else is evaluated as ECL. Results list {} at a time.",
if style.display { "on" } else { "off" },
if style.count { "on" } else { "off" },
QUERY_LIMIT
);
continue;
}
":display" => {
style.display = !style.display;
style.count &= !style.display;
println!(" Terms {}.", if style.display { "on" } else { "off" });
continue;
}
":count" => {
style.count = !style.count;
style.display &= !style.count;
println!(" Totals only {}.", if style.count { "on" } else { "off" });
continue;
}
":stats" => {
presentation::manifest(&manifest, Some(&path.display().to_string()));
continue;
}
_ => {}
}
let browse = if let Some(words) = text.strip_prefix(":search ") {
let request: BatchRequest =
serde_json::from_value(serde_json::json!({"search": words.trim()}))?;
let mut answer = Vec::new();
search_response(&store, &labels, words.trim(), &request, &mut answer)
.and_then(|()| {
show(answer, true, |v| presentation::search(v, words.trim()))
})
.map(Some)
} else if let Some(code) = text.strip_prefix(":lookup ") {
let mut answer = Vec::new();
concept_response(&store, &labels, code.trim(), &mut answer)
.and_then(|()| show(answer, true, presentation::concept))
.map(Some)
} else {
Ok(None)
};
match browse {
Ok(Some(())) => {
println!();
continue;
}
Ok(None) => {}
Err(error) => {
println!(" {}\n", presentation::clean_message(&format!("{error:#}")));
continue;
}
}
let start = Instant::now();
let outcome = parse(text)
.and_then(|expression| Ok(eval::evaluate_result(&store, &expression)?));
let elapsed = start.elapsed().as_secs_f64() * 1000.0;
match outcome {
Ok(result) => {
println!(" {} in {elapsed:.3} ms", presentation::total(&result));
let mut out = io::BufWriter::new(io::stdout().lock());
let shown = emit(
&store,
&path,
&mut display,
&result,
&style,
Some(QUERY_LIMIT),
&mut out,
);
out.flush()?;
if let Err(error) = shown {
println!(" {}", presentation::clean_message(&format!("{error:#}")));
}
}
Err(error) => {
println!("{}", presentation::clean_message(&format!("{error:#}")))
}
}
println!();
}
}
"diff" => {
let style = Style::take(&mut args, human, json)?;
ensure!(!style.csv, "diff has no CSV output; use --json");
ensure!(
args.len() == 4,
"Usage: diff OLD NEW ECL [--display|--count]"
);
let expression = parse(&args[3])?;
let (old, new) = (library::resolve(&args[1])?, library::resolve(&args[2])?);
let (old_edition, old_codes) = evaluate_for_diff(&old, &expression, &query_config)?;
let (new_edition, new_codes) = evaluate_for_diff(&new, &expression, &query_config)?;
let removed: Vec<_> = old_codes
.iter()
.filter(|(code, _)| new_codes.binary_search_by_key(code, |(c, _)| *c).is_err())
.copied()
.collect();
let added: Vec<_> = new_codes
.iter()
.filter(|(code, _)| old_codes.binary_search_by_key(code, |(c, _)| *c).is_err())
.copied()
.collect();
let unchanged = old_codes.len() - removed.len();
if !human {
let mut out = io::BufWriter::new(io::stdout().lock());
writeln!(
out,
"{}",
serde_json::json!({
"ecl": args[3],
"old": {"store": old, "edition": old_edition, "total": old_codes.len()},
"new": {"store": new, "edition": new_edition, "total": new_codes.len()},
"unchanged": unchanged,
"added": added.iter().map(|(c, _)| c.to_string()).collect::<Vec<_>>(),
"removed": removed.iter().map(|(c, _)| c.to_string()).collect::<Vec<_>>(),
})
)?;
out.flush()?;
return Ok(());
}
println!("{}\n", presentation::heading("SNOMED ECL / diff"));
println!(" Expression {}", presentation::clean(&args[3]));
println!(
" Old {} ({}), {} results",
old.display(),
presentation::clean(&old_edition),
presentation::number(old_codes.len())
);
println!(
" New {} ({}), {} results",
new.display(),
presentation::clean(&new_edition),
presentation::number(new_codes.len())
);
println!(
"\n {} unchanged, {} added, {} removed",
presentation::number(unchanged),
presentation::number(added.len()),
presentation::number(removed.len())
);
if style.count {
return Ok(());
}
print_diff_side("Added", &added, &new, style.display)?;
print_diff_side("Removed", &removed, &old, style.display)?;
if added.is_empty() && removed.is_empty() {
println!("\n The expression selects the same concepts in both indexes.");
}
}
"pack" => {
ensure!(
(3..=5).contains(&args.len()),
"Usage: pack STORE DESTINATION_FILE [--uncompressed|--block-kib 16|64]"
);
let mut options = snomed_ecl_engine::store::PackOptions::default();
match args.get(3).map(String::as_str) {
None => {}
Some("--uncompressed") if args.len() == 4 => options.compress = false,
Some("--block-kib") if args.len() == 5 => {
options.block_bytes = args[4]
.parse::<u32>()?
.checked_mul(1024)
.context("Block size overflow")?;
}
_ => bail!("Usage: pack STORE DESTINATION_FILE [--uncompressed|--block-kib 16|64]"),
}
let start = Instant::now();
snomed_ecl_engine::store::pack_with_options(
Path::new(&args[1]),
Path::new(&args[2]),
options,
)?;
let bytes = std::fs::metadata(&args[2])?.len();
if human {
println!(
"Packed index: {} ({:.2} MiB)",
presentation::clean(&args[2]),
bytes as f64 / 1_048_576.0
);
} else {
println!(
"{}",
serde_json::json!({"bytes":bytes,"elapsed_seconds":start.elapsed().as_secs_f64()})
);
}
}
"verify" => {
ensure!(args.len() <= 2, "Usage: verify [STORE]");
let (store, _) = workspace::resolve(args.get(1).map(String::as_str))?;
let start = Instant::now();
let result = snomed_ecl_engine::store::verify(&store)?;
if human {
println!(
"Verified {} sections and {} concepts in {:.2}s",
result.sections,
presentation::number(result.concepts),
start.elapsed().as_secs_f64()
);
} else {
println!("{}", serde_json::to_string(&result)?);
}
}
#[cfg(not(feature = "import"))]
"add" => bail!("Adding a release needs the importer; use the default build"),
#[cfg(feature = "import")]
"add" => {
let sha256 = take_option(&mut args, "--sha256")?;
let name = take_option(&mut args, "--name")?;
let edition = take_option(&mut args, "--edition")?;
ensure!(
args.len() == 2,
"Usage: add ARCHIVE [--sha256 HEX] [--name NAME] [--edition URI]"
);
add_release(Path::new(&args[1]), sha256, name, edition, human)?;
}
#[cfg(not(feature = "download"))]
"download" => bail!("Downloading needs the `download` feature; use the default build"),
#[cfg(feature = "download")]
"download" => {
let list = take_flag(&mut args, "--list");
let keep = take_flag(&mut args, "--keep-archive");
let wanted = take_option(&mut args, "--release")?;
let name = take_option(&mut args, "--name")?;
ensure!(
args.len() <= 2,
"Usage: download [ITEM] [--list | --release ID] [--name NAME] [--keep-archive]"
);
let item = download::item(args.get(1).map_or("uk-monolith", String::as_str))?;
if list {
let releases = download::releases(item, false)?;
if human {
println!("{}\n", presentation::heading("SNOMED ECL / TRUD releases"));
for release in &releases {
println!(
" {} {:>8} {}",
presentation::clean(&release.release_date),
presentation::bytes(release.archive_file_size_bytes),
presentation::clean(&release.id)
);
}
println!("\n Download one with `download --release ID`, or the newest with `download`.");
} else {
let mut out = io::stdout().lock();
for release in &releases {
writeln!(
out,
"{}",
serde_json::json!({
"id": release.id,
"name": release.name,
"release_date": release.release_date,
"bytes": release.archive_file_size_bytes,
"sha256": release.archive_file_sha256,
})
)?;
}
}
return Ok(());
}
let releases = download::releases(item, wanted.is_none())?;
let release = match &wanted {
Some(id) => releases
.into_iter()
.find(|release| &release.id == id)
.with_context(|| {
format!("TRUD has no release {id}; `download --list` shows them")
})?,
None => releases
.into_iter()
.next()
.context("TRUD lists no releases for this item")?,
};
eprintln!(
" {} ({}), {}",
presentation::clean(&release.name),
presentation::clean(&release.release_date),
presentation::bytes(release.archive_file_size_bytes)
);
let archive = download::fetch(&release, &library::home()?.join("downloads"))?;
let added = add_release(
&archive,
Some(release.archive_file_sha256.clone()),
name,
None,
human,
);
if !keep {
let _ = std::fs::remove_file(&archive);
}
added?;
}
"remove" => {
let yes = take_flag(&mut args, "--yes");
ensure!(args.len() == 2, "Usage: remove NAME [--yes]");
remove_index(&args[1], yes)?;
}
#[cfg(not(feature = "import"))]
"import" | "add-refsets" => {
bail!("Import support was excluded; rebuild with --features import")
}
#[cfg(feature = "import")]
"add-refsets" => {
ensure!(
args.len() == 6,
"Usage: add-refsets BASE_STORE ARCHIVE DESTINATION RELEASE_DATE SHA256"
);
let start = Instant::now();
eprintln!(" Verifying and adding supplementary refsets...");
let manifest = snomed_ecl_engine::import::add_refsets_snapshot(
Path::new(&args[1]),
Path::new(&args[2]),
Path::new(&args[3]),
&args[4],
&args[5],
)?;
if human {
presentation::manifest(&manifest, Some(&args[3]));
} else {
println!("{}", serde_json::to_string_pretty(&manifest)?);
}
eprintln!(
" Supplement complete in {:.2}s",
start.elapsed().as_secs_f64()
);
}
#[cfg(feature = "import")]
"import" => {
ensure!(
args.len() == 5 || args.len() == 6,
"Usage: import ARCHIVE DESTINATION EDITION_URI SHA256 [DISPLAY_REFSET_IDS]"
);
let refsets = if args.len() == 6 {
args[5]
.split(',')
.map(str::parse)
.collect::<Result<Vec<_>, _>>()?
} else {
UK_DISPLAY_REFSETS.to_vec()
};
let start = Instant::now();
let mut stage = 0;
let manifest = import_snapshot_with_progress(
Path::new(&args[1]),
Path::new(&args[2]),
&ImportOptions {
edition: args[3].clone(),
expected_sha256: args[4].clone(),
display_refsets: refsets,
},
|message| {
stage += 1;
eprintln!(
" [{stage}/{}] {message} ({:.1}s elapsed)",
snomed_ecl_engine::import::IMPORT_STAGES,
start.elapsed().as_secs_f64()
);
},
)?;
if human {
presentation::manifest(&manifest, Some(&args[2]));
eprintln!(
"\n Import complete in {:.2}s",
start.elapsed().as_secs_f64()
);
} else {
println!(
"{}",
serde_json::to_string_pretty(
&serde_json::json!({"manifest": manifest, "elapsed_seconds": start.elapsed().as_secs_f64()})
)?
);
}
}
"stats" => {
ensure!(args.len() <= 2, "Usage: stats [STORE]");
let (store, source) = workspace::resolve(args.get(1).map(String::as_str))?;
let manifest = Manifest::read(&store)
.with_context(|| format!("Cannot read the index at {}", store.display()))?;
if human {
let location = format!("{} ({})", store.display(), source.describe());
presentation::manifest(&manifest, Some(&location));
} else {
println!("{}", serde_json::to_string_pretty(&manifest)?);
}
}
"expand" => {
let style = Style::take(&mut args, human, json)?;
ensure!(
args.len() == 2 || args.len() == 3,
"Usage: expand [STORE] ECL [--display|--count]"
);
let text = args[args.len() - 1].clone();
let explicit = (args.len() == 3).then(|| args[1].as_str());
let (path, _) = workspace::resolve(explicit)?;
let parse_start = Instant::now();
let expression = parse(&text)?;
let parse_ms = parse_start.elapsed().as_secs_f64() * 1000.0;
if human {
eprintln!(" Opening and verifying index...");
}
let open_start = Instant::now();
let mut store = NumericStore::open(&path)?;
if let Some(config) = &query_config {
store.config = config.clone();
}
let open_seconds = open_start.elapsed().as_secs_f64();
let eval_start = Instant::now();
let result = eval::evaluate_result(&store, &expression)?;
let eval_ms = eval_start.elapsed().as_secs_f64() * 1000.0;
if human {
eprintln!(
" {} | query {:.3} ms | parse {:.3} ms | index {:.3} s",
presentation::total(&result),
eval_ms,
parse_ms,
open_seconds
);
}
let mut display = None;
let mut out = io::BufWriter::new(io::stdout().lock());
let limit = human.then_some(EXPAND_LIMIT);
emit(
&store,
&path,
&mut display,
&result,
&style,
limit,
&mut out,
)?;
out.flush()?;
}
"batch" => {
let workers = match args.iter().position(|a| a == "--workers") {
Some(i) => {
ensure!(i + 1 < args.len(), "--workers needs a count");
let count: usize = args[i + 1].parse().context("--workers needs a count")?;
args.drain(i..i + 2);
count.max(1)
}
None => 1,
};
ensure!(
args.len() <= 2,
"Usage: batch [STORE] [--workers N] (JSON lines on stdin)"
);
let (directory, _) = workspace::resolve(args.get(1).map(String::as_str))?;
let start = Instant::now();
let mut store = NumericStore::open(&directory)?;
if let Some(config) = &query_config {
store.config = config.clone();
}
let manifest = Manifest::read(&directory)?;
let config_sha256 = store.config.fingerprint()?;
eprintln!(
"Store opened in {:.3} seconds",
start.elapsed().as_secs_f64()
);
let labels = Labels::new(&directory);
let respond =
|line: &[u8]| batch_line(&store, &manifest, &config_sha256, &labels, line);
let mut input = io::stdin().lock();
let mut read = |line: &mut Vec<u8>| -> Result<bool> {
line.clear();
if (&mut input).take(524289).read_until(b'\n', line)? == 0 {
return Ok(false);
}
ensure!(line.len() <= 524288, "Batch request exceeds 512 KiB");
Ok(true)
};
if workers == 1 {
let mut out = io::BufWriter::new(io::stdout().lock());
let mut line = Vec::new();
while read(&mut line)? {
out.write_all(&respond(&line))?;
out.flush()?;
}
return Ok(());
}
let (requests, queue) = std::sync::mpsc::sync_channel::<Vec<u8>>(workers * 4);
let queue = std::sync::Mutex::new(queue);
let (answers, finished) = std::sync::mpsc::channel::<Vec<u8>>();
std::thread::scope(|scope| -> Result<()> {
for _ in 0..workers {
let (queue, answers, respond) = (&queue, answers.clone(), &respond);
scope.spawn(move || loop {
let next = queue.lock().map(|queue| queue.recv());
let Ok(Ok(line)) = next else { break };
if answers.send(respond(&line)).is_err() {
break;
}
});
}
drop(answers);
let writer = scope.spawn(move || -> io::Result<()> {
let mut out = io::BufWriter::new(io::stdout().lock());
while let Ok(answer) = finished.recv() {
out.write_all(&answer)?;
while let Ok(answer) = finished.try_recv() {
out.write_all(&answer)?;
}
out.flush()?;
}
Ok(())
});
let mut line = Vec::new();
while read(&mut line)? {
if requests.send(line.clone()).is_err() {
break;
}
}
drop(requests);
writer.join().expect("batch writer panicked")?;
Ok(())
})?;
}
"search" => {
let within = take_option(&mut args, "--within")?;
let limit = take_option(&mut args, "--limit")?
.map(|limit| limit.parse::<usize>())
.transpose()
.context("--limit takes a number")?;
let inactive = take_flag(&mut args, "--inactive");
ensure!(
args.len() >= 2,
"Usage: search [STORE] TEXT [--within ECL] [--limit N] [--inactive]"
);
let (explicit, words) = if args.len() > 2 && library::resolve(&args[1]).is_ok() {
(Some(args[1].as_str()), &args[2..])
} else {
(None, &args[1..])
};
let text = words.join(" ");
let (path, _) = workspace::resolve(explicit)?;
let store = open_store(&path, &query_config)?;
let request: BatchRequest = serde_json::from_value(serde_json::json!({
"search": text,
"within": within,
"limit": limit,
"include_inactive": inactive,
}))?;
let mut answer = Vec::new();
search_response(&store, &Labels::new(&path), &text, &request, &mut answer)?;
show(answer, human, |value| presentation::search(value, &text))?;
}
"lookup" | "concept" | "history" => {
let usage = format!("Usage: {command} [STORE] SCTID");
ensure!(args.len() == 2 || args.len() == 3, "{usage}");
let explicit = (args.len() == 3).then(|| args[1].as_str());
let sctid = args[args.len() - 1].clone();
let (path, _) = workspace::resolve(explicit)?;
let store = open_store(&path, &query_config)?;
let labels = Labels::new(&path);
let mut answer = Vec::new();
if command == "history" {
history_response(&store, &labels, &sctid, &mut answer)?;
show(answer, human, presentation::history)?;
} else {
concept_response(&store, &labels, &sctid, &mut answer)?;
show(answer, human, presentation::concept)?;
}
}
"hierarchy" => {
let with_display = args.iter().any(|s| s == "--display");
args.retain(|s| s != "--display");
ensure!(
args.len() == 3 || args.len() == 4,
"Usage: hierarchy [STORE] OPERATOR SCTID [--display]"
);
let (operator, sctid) = (&args[args.len() - 2], &args[args.len() - 1]);
let (ancestors, direct, include_self) = match operator.as_str() {
"<" => (false, false, false),
"<<" => (false, false, true),
"<!" => (false, true, false),
"<<!" => (false, true, true),
">" => (true, false, false),
">>" => (true, false, true),
">!" => (true, true, false),
">>!" => (true, true, true),
_ => bail!("Unsupported hierarchy operator; this command is not an ECL parser"),
};
let explicit = (args.len() == 4).then(|| args[1].as_str());
let (path, _) = workspace::resolve(explicit)?;
let store = NumericStore::open(&path)?;
let codes = store.hierarchy(sctid.parse()?, ancestors, direct, include_self);
let mut displays = if with_display {
Some(DisplayStore::open(&path)?)
} else {
None
};
let mut out = io::BufWriter::new(io::stdout().lock());
for code in codes {
if let Some(ref mut display) = displays {
let text =
display.get(store.ordinal(code).context("Missing result concept")?)?;
writeln!(
out,
"{}",
serde_json::json!({"code": code.to_string(), "display": text})
)?;
} else if json {
writeln!(out, "{}", serde_json::json!({"code": code.to_string()}))?;
} else {
writeln!(out, "{code}")?;
}
}
out.flush()?;
}
_ => bail!("Unknown command. Run --help for available commands"),
}
Ok(())
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct BatchRequest {
#[serde(default)]
id: Option<serde_json::Value>,
#[serde(default)]
ecl: Option<String>,
#[serde(default)]
concept: Option<String>,
#[serde(default)]
search: Option<String>,
#[serde(default)]
history: Option<String>,
#[serde(default)]
within: Option<String>,
#[serde(default)]
include_inactive: bool,
#[serde(default)]
count_only: bool,
#[serde(default)]
display: bool,
#[serde(default)]
offset: Option<usize>,
#[serde(default)]
limit: Option<usize>,
}
struct Labels<'a> {
path: &'a Path,
store: std::sync::OnceLock<DisplayStore>,
}
impl<'a> Labels<'a> {
fn new(path: &'a Path) -> Self {
Self {
path,
store: std::sync::OnceLock::new(),
}
}
fn get(&self) -> Result<&DisplayStore> {
if let Some(store) = self.store.get() {
return Ok(store);
}
let opened = DisplayStore::open(self.path)?;
opened.prefetch()?;
Ok(self.store.get_or_init(|| opened))
}
}
struct Codes<'a> {
store: &'a NumericStore,
ordinals: &'a [u32],
}
struct Labelled {
codes: Vec<u64>,
displays: Vec<Option<String>>,
actives: Vec<bool>,
}
impl serde::Serialize for Labelled {
fn serialize<S: serde::Serializer>(
&self,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
use serde::ser::SerializeSeq;
let mut seq = serializer.serialize_seq(Some(self.codes.len()))?;
for ((code, display), active) in self.codes.iter().zip(&self.displays).zip(&self.actives) {
seq.serialize_element(&serde_json::json!({
"code": code.to_string(),
"display": display,
"active": active,
}))?;
}
seq.end()
}
}
impl serde::Serialize for Codes<'_> {
fn serialize<S: serde::Serializer>(
&self,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
use serde::ser::SerializeSeq;
let mut seq = serializer.serialize_seq(Some(self.ordinals.len()))?;
for &ordinal in self.ordinals {
seq.serialize_element(&self.store.ids[ordinal as usize].to_string())?;
}
seq.end()
}
}
#[derive(serde::Serialize)]
struct BatchResponse<'a> {
edition: &'a str,
query_config_sha256: &'a str,
#[serde(skip_serializing_if = "Vec::is_empty")]
supplements: Vec<&'a str>,
total: usize,
#[serde(skip_serializing_if = "Option::is_none")]
offset: Option<usize>,
parse_ms: f64,
eval_ms: f64,
#[serde(skip_serializing_if = "Option::is_none")]
codes: Option<Codes<'a>>,
#[serde(skip_serializing_if = "Option::is_none")]
concepts: Option<Labelled>,
#[serde(skip_serializing_if = "Option::is_none")]
rows: Option<&'a [std::collections::BTreeMap<String, snomed_ecl_engine::store::MemberValue>]>,
#[serde(skip_serializing_if = "Option::is_none")]
values: Option<&'a [snomed_ecl_engine::store::MemberValue]>,
#[serde(skip_serializing_if = "Option::is_none")]
result_type: Option<&'static str>,
}
const SEARCH_CANDIDATES: usize = 400;
fn search_response(
store: &NumericStore,
labels: &Labels,
text: &str,
request: &BatchRequest,
out: &mut impl Write,
) -> Result<()> {
let start = Instant::now();
let Some(index) = store.search.get()? else {
writeln!(
out,
"{}",
serde_json::json!({"error":"Unsupported","message":"This index has no word index; rebuild it with import"})
)?;
return Ok(());
};
let mut candidates = index.matches(text);
if !request.include_inactive {
candidates.retain(|&ordinal| store.is_active(ordinal));
}
if let Some(within) = &request.within {
let expression = match ecl::parse(within) {
Ok(expression) => expression,
Err(error) => {
writeln!(
out,
"{}",
serde_json::json!({"error":format!("{:?}", error.kind), "offset":error.offset, "message":error.message})
)?;
return Ok(());
}
};
let bound = match eval::evaluate(store, &expression) {
Ok(bound) => bound,
Err(error) => {
writeln!(out, "{}", serde_json::json!({"error":format!("{error:?}")}))?;
return Ok(());
}
};
candidates.retain(|ordinal| bound.binary_search(ordinal).is_ok());
}
let total = candidates.len();
let labels = labels.get()?;
let key = |&ordinal: &u32| (labels.label_bytes(ordinal).unwrap_or(u32::MAX), ordinal);
let keep = SEARCH_CANDIDATES.max(request.limit.unwrap_or(0));
if candidates.len() > keep {
candidates.select_nth_unstable_by_key(keep, key);
candidates.truncate(keep);
}
candidates.sort_by_key(key);
let mut query_words = Vec::new();
snomed_ecl_engine::store::words(text, &mut query_words);
let mut scored = Vec::with_capacity(candidates.len());
for ordinal in candidates {
let label = labels.get(ordinal)?;
let score = label
.as_deref()
.map_or(i32::MIN, |label| score(label, text, &query_words));
scored.push((
score,
label,
store.ids[ordinal as usize],
store.is_active(ordinal),
));
}
scored.sort_by(|a, b| {
b.0.cmp(&a.0)
.then_with(|| {
a.1.as_ref()
.map_or(0, |l| l.len())
.cmp(&b.1.as_ref().map_or(0, |l| l.len()))
})
.then_with(|| a.2.cmp(&b.2))
});
let limit = request.limit.unwrap_or(50);
scored.truncate(limit);
let concepts: Vec<_> = scored
.into_iter()
.map(|(_, display, code, active)| {
serde_json::json!({"code": code.to_string(), "display": display, "active": active})
})
.collect();
serde_json::to_writer(
&mut *out,
&serde_json::json!({
"total": total,
"concepts": concepts,
"search_ms": start.elapsed().as_secs_f64() * 1000.0,
}),
)?;
writeln!(out)?;
Ok(())
}
fn score(label: &str, query: &str, query_words: &[String]) -> i32 {
let mut label_words = Vec::new();
snomed_ecl_engine::store::words(label, &mut label_words);
let mut score = 0;
if label.eq_ignore_ascii_case(query.trim()) {
score += 1000;
}
if label
.to_ascii_lowercase()
.starts_with(&query.trim().to_ascii_lowercase())
{
score += 200;
}
for (position, word) in query_words.iter().enumerate() {
match label_words.iter().position(|w| w == word) {
Some(at) => {
score += 60;
if at == position {
score += 20;
}
}
None if label_words.iter().any(|w| w.starts_with(word)) => score += 25,
None => {}
}
}
score -= i32::try_from(label_words.len().saturating_sub(query_words.len())).unwrap_or(0) * 3;
score
}
fn history_response(
store: &NumericStore,
labels: &Labels,
sctid: &str,
out: &mut impl Write,
) -> Result<()> {
let Some(ordinal) = sctid.parse::<u64>().ok().and_then(|id| store.ordinal(id)) else {
writeln!(
out,
"{}",
serde_json::json!({"error":"NotFound","concept":sctid})
)?;
return Ok(());
};
let Some(index) = store.history.get()? else {
writeln!(
out,
"{}",
serde_json::json!({"error":"Unsupported","message":"This index has no history section; rebuild it with import"})
)?;
return Ok(());
};
let labels = labels.get()?;
let describe =
|rows: Vec<snomed_ecl_engine::store::Association>| -> Result<Vec<serde_json::Value>> {
rows.into_iter()
.map(|row| {
let association = store.ordinal(row.refset);
Ok(serde_json::json!({
"association": {
"code": row.refset.to_string(),
"display": association.map(|o| labels.get(o)).transpose()?.flatten(),
},
"concept": {
"code": store.ids[row.concept as usize].to_string(),
"display": labels.get(row.concept)?,
"active": store.is_active(row.concept),
},
}))
})
.collect()
};
let successors = describe(index.successors(ordinal))?;
let predecessors = describe(index.predecessors(ordinal))?;
serde_json::to_writer(
&mut *out,
&serde_json::json!({
"concept": sctid,
"active": store.is_active(ordinal),
"successors": successors,
"predecessors": predecessors,
}),
)?;
writeln!(out)?;
Ok(())
}
fn concept_response(
store: &NumericStore,
labels: &Labels,
sctid: &str,
out: &mut impl Write,
) -> Result<()> {
let Ok(sctid) = sctid.parse::<u64>() else {
writeln!(
out,
"{}",
serde_json::json!({"error":"InvalidRequest","message":"Concept must be an SCTID"})
)?;
return Ok(());
};
let index = labels.get()?;
let start = Instant::now();
match snomed_ecl_engine::detail::describe(store, index, sctid)? {
Some(detail) => {
let mut value = serde_json::to_value(&detail)?;
value["lookup_ms"] = serde_json::json!(start.elapsed().as_secs_f64() * 1000.0);
serde_json::to_writer(&mut *out, &value)?;
}
None => serde_json::to_writer(
&mut *out,
&serde_json::json!({"error":"NotFound","concept":sctid.to_string()}),
)?,
}
writeln!(out)?;
Ok(())
}
fn window<'a>(ordinals: &'a [u32], request: &BatchRequest) -> &'a [u32] {
let start = request.offset.unwrap_or(0).min(ordinals.len());
let end = match request.limit {
Some(limit) => start.saturating_add(limit).min(ordinals.len()),
None => ordinals.len(),
};
&ordinals[start..end]
}
fn batch_line(
store: &NumericStore,
manifest: &Manifest,
config_sha256: &str,
labels: &Labels,
line: &[u8],
) -> Vec<u8> {
let mut out = Vec::new();
let (id, result) = match serde_json::from_slice::<BatchRequest>(line) {
Ok(request) => (
request.id.clone(),
batch_response(store, manifest, config_sha256, &request, labels, &mut out),
),
Err(_) => {
let id = serde_json::from_slice::<serde_json::Value>(line)
.ok()
.and_then(|value| value.get("id").cloned());
(
id,
writeln!(out, "{{\"error\":\"InvalidRequest\"}}").map_err(Into::into),
)
}
};
if let Err(error) = result {
out.clear();
let _ = writeln!(
out,
"{}",
serde_json::json!({"error":"Internal","message":error.to_string()})
);
}
match id {
Some(id) if out.first() == Some(&b'{') => {
let mut tagged = format!("{{\"id\":{id}").into_bytes();
if out.get(1) != Some(&b'}') {
tagged.push(b',');
}
tagged.extend_from_slice(&out[1..]);
tagged
}
_ => out,
}
}
fn batch_response(
store: &NumericStore,
manifest: &Manifest,
config_sha256: &str,
request: &BatchRequest,
labels: &Labels,
out: &mut impl Write,
) -> Result<()> {
if let Some(sctid) = &request.concept {
return concept_response(store, labels, sctid, out);
}
if let Some(text) = &request.search {
return search_response(store, labels, text, request, out);
}
if let Some(sctid) = &request.history {
return history_response(store, labels, sctid, out);
}
let start = Instant::now();
let Some(ecl_text) = &request.ecl else {
writeln!(
out,
"{}",
serde_json::json!({"error":"InvalidRequest","message":"Give either ecl or concept"})
)?;
return Ok(());
};
let expression = match ecl::parse(ecl_text) {
Ok(expression) => expression,
Err(error) => {
writeln!(
out,
"{}",
serde_json::json!({"error":format!("{:?}", error.kind), "offset":error.offset, "message":error.message})
)?;
return Ok(());
}
};
let parse_ms = start.elapsed().as_secs_f64() * 1000.0;
let start = Instant::now();
let result = match eval::evaluate_result(store, &expression) {
Ok(result) => result,
Err(error) => {
writeln!(out, "{}", serde_json::json!({"error":format!("{error:?}")}))?;
return Ok(());
}
};
let eval_ms = start.elapsed().as_secs_f64() * 1000.0;
let mut labelled = None;
if request.display && !request.count_only {
if let eval::QueryResult::Concepts(ordinals) = &result {
let ordinals = window(ordinals, request);
let index = labels.get()?;
let mut codes = Vec::with_capacity(ordinals.len());
let mut texts = Vec::with_capacity(ordinals.len());
let mut actives = Vec::with_capacity(ordinals.len());
for &ordinal in ordinals {
codes.push(store.ids[ordinal as usize]);
texts.push(index.get(ordinal)?);
actives.push(store.is_active(ordinal));
}
labelled = Some(Labelled {
codes,
displays: texts,
actives,
});
}
}
serde_json::to_writer(
&mut *out,
&BatchResponse {
edition: &manifest.edition,
query_config_sha256: config_sha256,
supplements: manifest
.supplements
.iter()
.map(|s| s.archive_sha256.as_str())
.collect(),
total: result.len(),
offset: request.offset.filter(|_| !request.count_only),
parse_ms,
eval_ms,
codes: match &result {
eval::QueryResult::Concepts(ordinals)
if !request.count_only && labelled.is_none() =>
{
Some(Codes {
store,
ordinals: window(ordinals, request),
})
}
_ => None,
},
concepts: labelled,
rows: match &result {
eval::QueryResult::Rows(rows) if !request.count_only => Some(rows),
_ => None,
},
values: match &result {
eval::QueryResult::Values(values) if !request.count_only => Some(values),
_ => None,
},
result_type: match result {
eval::QueryResult::Rows(_) => Some("rows"),
eval::QueryResult::Values(_) => Some("values"),
_ => None,
},
},
)?;
writeln!(out)?;
Ok(())
}
const QUERY_LIMIT: usize = 40;
const EXPAND_LIMIT: usize = 200;
const DIFF_LIMIT: usize = 40;
fn evaluate_for_diff(
path: &Path,
expression: &ecl::Expr,
config: &Option<snomed_ecl_engine::config::QueryConfig>,
) -> Result<(String, Vec<(u64, u32)>)> {
let mut store = NumericStore::open(path)
.with_context(|| format!("Cannot open the index at {}", path.display()))?;
if let Some(config) = config {
store.config = config.clone();
}
let manifest = Manifest::read(path)?;
let result = eval::evaluate_result(&store, expression)?;
let eval::QueryResult::Concepts(ordinals) = result else {
bail!("diff compares concept results; this projection returns values or rows")
};
let mut codes: Vec<_> = ordinals
.into_iter()
.map(|ordinal| (store.ids[ordinal as usize], ordinal))
.collect();
codes.sort_unstable();
Ok((manifest.edition, codes))
}
fn print_diff_side(
label: &str,
codes: &[(u64, u32)],
path: &Path,
with_display: bool,
) -> Result<()> {
if codes.is_empty() {
return Ok(());
}
println!(
"\n {} ({})",
presentation::heading(label),
presentation::number(codes.len())
);
let mut display = if with_display {
Some(DisplayStore::open(path)?)
} else {
None
};
let shown = codes.len().min(DIFF_LIMIT);
for &(code, ordinal) in &codes[..shown] {
match display.as_mut() {
Some(display) => println!(
" {code:<20} {}",
display
.get(ordinal)?
.as_deref()
.map(presentation::clean)
.as_deref()
.unwrap_or("(no display)")
),
None => println!(" {code}"),
}
}
if shown < codes.len() {
println!(
" ... {} more. Use --json for every code.",
presentation::number(codes.len() - shown)
);
}
Ok(())
}
struct Style {
display: bool,
explicit_display: bool,
count: bool,
csv: bool,
json: bool,
human: bool,
}
impl Style {
fn take(args: &mut Vec<String>, human: bool, json: bool) -> Result<Self> {
let asked = args.iter().any(|s| s == "--display");
let count = args.iter().any(|s| s == "--count");
let codes = args.iter().any(|s| s == "--codes");
let csv = args.iter().any(|s| s == "--csv");
ensure!(
[asked, count, codes, csv]
.iter()
.filter(|&&flag| flag)
.count()
<= 1,
"Choose one of --display, --count, --codes and --csv"
);
ensure!(!(csv && json), "Choose either --csv or --json");
args.retain(|s| !matches!(s.as_str(), "--display" | "--count" | "--codes" | "--csv"));
if let Some(unknown) = args.iter().skip(1).find(|s| s.starts_with("--")) {
bail!("Unknown option {unknown}. Run `{} --help`", args[0]);
}
Ok(Self {
display: asked || csv || (human && !count && !codes),
explicit_display: asked || csv,
count,
csv,
json,
human,
})
}
}
fn csv_field(text: &str) -> String {
let text = if text.starts_with(['=', '+', '-', '@', '\t', '\r']) {
format!("'{text}")
} else {
text.to_owned()
};
if text.contains([',', '"', '\n', '\r']) {
format!("\"{}\"", text.replace('"', "\"\""))
} else {
text.to_owned()
}
}
fn emit(
store: &NumericStore,
path: &Path,
display: &mut Option<DisplayStore>,
result: &eval::QueryResult,
style: &Style,
limit: Option<usize>,
out: &mut impl Write,
) -> Result<()> {
let ordinals = match result {
eval::QueryResult::Concepts(ordinals) => ordinals,
eval::QueryResult::Values(values) => {
ensure!(
!style.explicit_display,
"--display and --csv need a concept result; this projection returns scalar values"
);
if style.count {
if style.json {
writeln!(
out,
"{}",
serde_json::json!({"total": values.len(), "result_type":"values"})
)?;
} else {
writeln!(out, "{}", values.len())?;
}
} else {
for value in values {
serde_json::to_writer(&mut *out, value)?;
writeln!(out)?;
}
}
return Ok(());
}
eval::QueryResult::Rows(rows) => {
ensure!(
!style.explicit_display,
"--display and --csv need a concept result; this projection returns rows"
);
if style.count {
if style.json {
writeln!(
out,
"{}",
serde_json::json!({"total": rows.len(), "result_type":"rows"})
)?;
} else {
writeln!(out, "{}", rows.len())?;
}
} else {
for row in rows {
serde_json::to_writer(&mut *out, row)?;
writeln!(out)?;
}
}
return Ok(());
}
};
if style.count {
if style.json {
writeln!(out, "{}", serde_json::json!({"total": ordinals.len()}))?;
} else {
writeln!(out, "{}", ordinals.len())?;
}
return Ok(());
}
if style.display && display.is_none() {
*display = Some(DisplayStore::open(path)?);
}
if style.csv {
let labels = display.as_mut().context("Display index was not opened")?;
writeln!(out, "code,display")?;
for &ordinal in ordinals {
let label = labels.get(ordinal)?.unwrap_or_default();
writeln!(out, "{},{}", store.ids[ordinal as usize], csv_field(&label))?;
}
return Ok(());
}
let shown = limit.unwrap_or(ordinals.len()).min(ordinals.len());
if style.human && style.display {
writeln!(out, "\n{}\n", presentation::heading("SNOMED ECL / results"))?;
writeln!(out, "{:<20} DISPLAY", "CODE")?;
writeln!(out, "{}", "-".repeat(64))?;
}
for &ordinal in &ordinals[..shown] {
let code = store.ids[ordinal as usize];
if style.display {
let label = display
.as_mut()
.context("Display index was not opened")?
.get(ordinal)?;
if style.human {
writeln!(
out,
"{code:<20} {}",
label
.as_deref()
.map(presentation::clean)
.as_deref()
.unwrap_or("(no display)")
)?;
} else {
writeln!(
out,
"{}",
serde_json::json!({"code": code.to_string(), "display": label})
)?;
}
} else if style.json {
writeln!(out, "{}", serde_json::json!({"code": code.to_string()}))?;
} else {
writeln!(out, "{code}")?;
}
}
if shown < ordinals.len() {
writeln!(
out,
"\n Listed {} of {}. Redirect output or use --json for every code.",
presentation::number(shown),
presentation::number(ordinals.len())
)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::csv_field;
#[test]
fn csv_fields_are_quoted_and_never_read_as_formulas() {
assert_eq!(csv_field("Asthma"), "Asthma");
assert_eq!(csv_field("Millers' asthma"), "Millers' asthma");
assert_eq!(csv_field("Fracture, left"), "\"Fracture, left\"");
assert_eq!(csv_field("A \"quoted\" term"), "\"A \"\"quoted\"\" term\"");
assert_eq!(csv_field("=SUM(A1)"), "'=SUM(A1)");
assert_eq!(csv_field("-5 degrees, cold"), "\"'-5 degrees, cold\"");
assert_eq!(csv_field("@home"), "'@home");
}
}