snomed-ecl-engine 0.2.1

Compact SNOMED CT ECL engine and RF2 index builder
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
use snomed_ecl_engine::store::Manifest;
use std::io::{self, IsTerminal};

pub fn human() -> bool {
    io::stdout().is_terminal()
}

pub fn clean(text: &str) -> String {
    text.chars()
        .flat_map(|c| {
            if c.is_control() {
                c.escape_default().collect::<Vec<_>>()
            } else {
                vec![c]
            }
        })
        .collect()
}

/// Escapes control characters as `clean` does, but keeps line breaks, so a
/// message built over several lines still reads as several lines.
pub fn clean_message(text: &str) -> String {
    text.split('\n').map(clean).collect::<Vec<_>>().join("\n")
}

pub fn heading(text: &str) -> String {
    if human()
        && std::env::var_os("NO_COLOR").is_none()
        && std::env::var("TERM").as_deref() != Ok("dumb")
    {
        format!("\x1b[1;36m{text}\x1b[0m")
    } else {
        text.to_owned()
    }
}

pub fn bytes(value: u64) -> String {
    match value {
        0..1_048_576 => format!("{:.0} KiB", value as f64 / 1024.0),
        1_048_576..1_073_741_824 => format!("{:.0} MiB", value as f64 / 1_048_576.0),
        _ => format!("{:.2} GiB", value as f64 / 1_073_741_824.0),
    }
}

/// Points at the byte offset an ECL parse error reported. Long expressions are
/// windowed so the caret stays beside the offending text on one terminal line.
pub fn caret(expression: &str, offset: usize) -> String {
    let offset = offset.min(expression.len());
    let line_start = expression[..offset].rfind('\n').map_or(0, |i| i + 1);
    let line_end = expression[offset..]
        .find('\n')
        .map_or(expression.len(), |i| offset + i);
    let (mut start, mut end) = (line_start, line_end);
    let mut prefix = "";
    let mut suffix = "";
    if end - start > 72 {
        start = start.max(offset.saturating_sub(36));
        while !expression.is_char_boundary(start) {
            start -= 1;
        }
        end = end.min(start + 72);
        while !expression.is_char_boundary(end) {
            end -= 1;
        }
        prefix = if start > line_start { "..." } else { "" };
        suffix = if end < line_end { "..." } else { "" };
    }
    let text = clean(&expression[start..end]);
    let column = clean(&expression[start..offset]).chars().count() + prefix.len();
    format!("  {prefix}{text}{suffix}\n  {}^", " ".repeat(column))
}

pub fn number(value: usize) -> String {
    let digits = value.to_string();
    let mut result = String::new();
    for (i, c) in digits.chars().enumerate() {
        if i > 0 && (digits.len() - i).is_multiple_of(3) {
            result.push(',');
        }
        result.push(c);
    }
    result
}

pub fn manifest(m: &Manifest, location: Option<&str>) {
    println!("{}\n", heading("SNOMED ECL / index"));
    if let Some(location) = location {
        println!("  Index       {}", clean(location));
    }
    println!("  Edition     {}", clean(&m.edition));
    println!("  Format      {}", m.format);
    println!(
        "  Concepts    {} active / {} total",
        number(m.active_concept_count),
        number(m.concept_count)
    );
    println!("  Hierarchy   {} edges", number(m.hierarchy_edges));
    println!(
        "  Attributes  {} ordinary / {} concrete",
        number(m.attributes),
        number(m.concrete_attributes)
    );
    println!("  Core        {:.2} MiB", m.core_bytes as f64 / 1_048_576.0);
    if let Some(membership) = &m.membership {
        println!(
            "  Membership  {:.2} MiB / {} refsets / {} concept pairs",
            membership.bytes as f64 / 1_048_576.0,
            number(membership.refset_count),
            number(membership.concept_pairs)
        );
    } else {
        println!("  Membership  Not built; reimport RF2 to enable refset queries");
    }
    println!(
        "  Displays    {:.2} MiB (separate file)",
        m.display_bytes as f64 / 1_048_576.0
    );
    if let Some(descriptions) = &m.descriptions {
        println!(
            "  Descriptions {:.2} MiB / {} terms / {} language memberships (loaded on demand)",
            descriptions.bytes as f64 / 1_048_576.0,
            number(descriptions.descriptions),
            number(descriptions.language_memberships)
        );
    }
    for supplement in &m.supplements {
        println!(
            "  Supplement  {} / {} refsets / {} new concepts",
            supplement.release_date,
            number(supplement.refset_ids.len()),
            number(supplement.added_concepts)
        );
    }
    if let Some(tables) = &m.member_tables {
        println!(
            "  Member fields {:.2} MiB / {} refsets (loaded on demand)",
            tables.iter().map(|t| t.bytes).sum::<u64>() as f64 / 1_048_576.0,
            number(tables.len())
        );
    }
    println!("\n  Manifest metadata only; each index file is verified when loaded.");
    println!("  ECL 2.3 support and open questions: docs/ecl-support.md.");
}

/// Prints discovered indexes, marking the selected one. Editions are shown as
/// the version segment alone; `stats` gives the full URI.
pub fn stores(found: &[crate::workspace::Found]) {
    println!("{}\n", heading("SNOMED ECL / indexes"));
    let home = crate::library::home()
        .map(|home| home.display().to_string())
        .unwrap_or_else(|_| "not available".into());
    if found.is_empty() {
        println!("  No indexes yet. Build one from an RF2 release:\n");
        println!("    snomed-ecl-engine add ARCHIVE.zip\n");
        println!("  The library folder is {}.", clean(&home));
        return;
    }
    // Library indexes by name; others by path.
    let labels: Vec<String> = found
        .iter()
        .map(|entry| match &entry.name {
            Some(name) => clean(name),
            None => clean(&entry.path.display().to_string()),
        })
        .collect();
    let width = labels
        .iter()
        .map(|label| label.chars().count())
        .max()
        .unwrap_or(5)
        .clamp(5, 48);
    println!(
        "    {:<width$}  {:<16}  {:>9}  {:>8}",
        "INDEX", "RELEASE", "CONCEPTS", "SIZE"
    );
    println!("  {}", "-".repeat(width + 43));
    for (entry, label) in found.iter().zip(&labels) {
        let release = match crate::library::edition_parts(&entry.edition) {
            Some((family, date)) => format!("{family} {}", crate::library::show_date(&date)),
            None => clean(&entry.edition),
        };
        println!(
            "  {} {:<width$}  {:<16}  {:>9}  {:>8}{}",
            if entry.selected { "*" } else { " " },
            label,
            release,
            number(entry.active_concepts),
            bytes(entry.bytes),
            if entry.packed { "" } else { "  directory" },
        );
    }
    println!("\n  * selected. Concepts are active concepts; `stats` has the full manifest.");
    println!("  Select one with `use NAME`, or `use uk` for the latest UK release.");
    println!("  Library folder: {}", clean(&home));
}

/// The command list, with the selected index and the next useful step. Shown
/// bare or with `help`, so it is the first thing most users read.
fn root() {
    println!("Evaluate SNOMED CT ECL against a local index built from an RF2 release.\n");
    println!("Usage: snomed-ecl-engine COMMAND [ARGS] [--json|--plain]\n");
    println!("Indexes:");
    println!("  download    Fetch a release from NHS England TRUD and add it");
    println!("  add         Build an index from an RF2 archive and select it");
    println!("  list        List your indexes and their releases");
    println!("  use         Select an index by name, such as uk or uk@2026-08");
    println!("  remove      Delete an index from the library");
    println!("  stats       Show an index's edition, counts and size");
    println!("  verify      Check every section of an index\n");
    println!("Building by hand:");
    println!("  inspect     Read an archive's release metadata");
    println!("  import      Build an index directory from an RF2 archive");
    println!("  add-refsets Add a simple RF2 refset supplement to an index");
    println!("  pack        Pack an index directory into one compressed file\n");
    println!("Query:");
    println!("  query       Evaluate ECL expressions interactively");
    println!("  expand      Evaluate one ECL expression");
    println!("  search      Find concepts by name");
    println!("  lookup      Describe one concept: terms, parents, attributes, refsets");
    println!("  history     Show what replaced a concept, and what it replaced");
    println!("  hierarchy   List a concept's parents, children, ancestors or descendants");
    println!("  diff        Compare one expression across two indexes");
    println!("  batch       Answer JSONL requests on stdin, for scripts and agents\n");
    println!("Run COMMAND --help for arguments and examples.\n");
    match crate::workspace::load().store {
        Some(store) => {
            println!("Selected index: {}", clean(&store.display().to_string()));
            println!(
                "Commands taking [STORE] use it unless given a name, a path or SNOMED_ECL_STORE."
            );
            println!("\nNext: snomed-ecl-engine query");
        }
        None => {
            println!("No index selected.");
            println!("\nNext: snomed-ecl-engine add ARCHIVE.zip, or `list` to see your indexes.");
        }
    }
    println!("\nOutput:");
    println!("  Terminal   Readable summaries and display tables");
    println!("  Redirected Plain code lines or JSON output for scripts");
    println!("  --json     Explicit JSON output (JSONL for expansion rows)");
    println!("  --plain    Plain script output, even in a terminal");
    println!("  NO_COLOR   Disable colour");
    println!(
        "\nEvaluates ECL 2.3. Queries it cannot answer fail explicitly; see docs/ecl-support.md."
    );
    println!("Agent setup and query workflow: SKILL.md");
}

pub fn help(command: Option<&str>) -> anyhow::Result<()> {
    println!("{}  {}\n", heading("SNOMED ECL"), env!("CARGO_PKG_VERSION"));
    match command {
        Some("pack") => println!("Usage: pack STORE DESTINATION_FILE [--uncompressed|--block-kib 16|64]\n\nPack a directory index or repack an existing container into one file.\nAll sections and semantic data are preserved. Default: zstd level 15, 64 KiB blocks.\n--uncompressed provides a comparison baseline. No RF2 reimport is needed.\nDESTINATION_FILE must not exist. Packing uses temporary disk space beside it.\nPublication requires hard-link support on that filesystem.\nUse the resulting file with stats, verify, expand, batch or add-refsets.\nCompression reduces file size; decoded query indexes still occupy memory."),
        Some("verify") => println!("Usage: verify [STORE]\n\nCheck every component checksum and its structure, including cold sections.\nSTORE may be a directory or a packed index file.\nDescription and member sections are checked individually.\nOrdinary queries continue to verify each section when it is first loaded."),
        None | Some("help") => root(),
        Some("import") => println!("Usage: import ARCHIVE DESTINATION EDITION_URI SHA256 [DISPLAY_REFSET_IDS]\n\nRequires one self-contained Snapshot ZIP, a versioned edition URI and\na trusted archive SHA-256. DESTINATION must not exist.\nOptional display refsets are comma-separated IDs in preference order.\nThe default is NHS clinical realm, pharmacy realm, then GB English.\n\nStages and elapsed time go to stderr. Redirected stdout contains JSON.\nThis build {} import support.", if cfg!(feature = "import") { "includes" } else { "excludes" }),
        Some("add-refsets") => println!("Usage: add-refsets BASE_STORE ARCHIVE DESTINATION RELEASE_DATE SHA256\n\nLoad simple concept refsets from a verified RF2 Snapshot ZIP.\nInclude new defining concepts and inferred is-a relationships when supplied.\nRELEASE_DATE is YYYYMMDD. DESTINATION must not exist.\nExisting definitions and populated refsets cannot be replaced.\nFor an updated supplement, start from the original base store.\nDescriptions and typed simple members are preserved when the base has those indexes. Arbitrary maps are not imported.\nThe base display index is required. See docs/indexes.md for scope and provenance."),
        Some("use") => println!("Usage: use [NAME | PATH | --clear]\n\nRemember one index, so later commands need no path. NAME is a library\nindex such as uk-20260826, a release such as uk@2026-08, or an edition\nalone, such as uk, for its latest release. With no argument, shows the\nselection. SNOMED_ECL_STORE overrides it for one shell."),
        Some("list" | "stores") => println!("Usage: list [FOLDER...]\n\nList the library's indexes by name and release, then any other index\nfound directly inside . and data, or inside the folders given. The\nlibrary folder is set by SNOMED_ECL_HOME, or defaults to the platform's\ndata folder. `stores` is another name for this command."),
        Some("add") => println!("Usage: add ARCHIVE [--sha256 HEX] [--name NAME] [--edition URI]\n\nBuild an index from an RF2 Snapshot ZIP, pack it into the library and\nselect it. The name defaults to the edition and release date, such as\nuk-20260826.\n\n  --sha256   The checksum your distributor published. Without it, the\n             archive's checksum is shown and you are asked to confirm it.\n  --name     Another name for the index.\n  --edition  The edition URI, when the archive does not name one.\n\nExample: snomed-ecl-engine add uk_sct2mo_42.5.0_20260826000001Z.zip --sha256 1330d2f2..."),
        Some("download") => println!("Usage: download [ITEM] [--list | --release ID] [--name NAME] [--keep-archive]\n\nFetch a release from NHS England's TRUD service, check it against the\nSHA-256 TRUD publishes, then build, pack and select it as `add` does.\nITEM is uk-monolith (the default) or a TRUD item number.\n\n  --list          List the item's releases instead\n  --release ID    Fetch this release rather than the newest\n  --name NAME     Another name for the index\n  --keep-archive  Keep the ZIP in the library's downloads folder\n\nSet TRUD_API_KEY to your TRUD API key first. Your TRUD account must be\nsubscribed to the item. The key is never printed."),
        Some("remove") => println!("Usage: remove NAME [--yes]\n\nDelete one index from the library, after asking. NAME is a library name,\na release such as uk@2026-08, or an edition such as uk; paths are not\naccepted, so nothing outside the library is deleted. --yes skips the\nquestion, for scripts."),
        Some("query") => println!("Usage: query [STORE] [--display|--count] [--config FILE]\n\nOpen one index and evaluate ECL expressions until :quit.\nThe index is opened and verified once, so later expressions answer immediately.\n  :display  toggle result terms\n  :count    toggle totals only\n  :stats    show the index manifest\n  :search TEXT  find concepts by name\n  :lookup CODE  describe one concept\nParse and evaluation errors return to the prompt and do not end the session.\nResults are listed in pages; the total is always reported in full.\nUse expand for one expression, or batch for scripted JSONL queries."),
        Some("diff") => println!("Usage: diff OLD_STORE NEW_STORE ECL [--display|--count] [--config FILE]\n\nEvaluate one expression against two indexes and report added and removed codes.\nUse it to see what a release or refset version changed for a definition.\nBoth indexes are opened in turn, not together.\nTerms are resolved from the index each code belongs to, so removed concepts\nstill get the term the older index held.\nRedirected output, or --json, gives the complete added and removed code sets.\nConcept results only; member projections returning values or rows are refused."),
        Some("inspect") => println!("Usage: inspect ARCHIVE

Read an RF2 archive's release metadata without importing it, and print the
import command for it. Reports the SHA-256, the release date, which required
Snapshot files are present, and the edition URIs the archive's own module
dependencies declare.

The checksum proves the file is intact, not where it came from. Compare it
with the value the release distributor published before importing."),
        Some("stats") => println!("Usage: stats [STORE] [--json|--plain]\n\nRead manifest metadata without loading the numeric index.\nExample: snomed-ecl-engine stats --json"),
        Some("expand") => println!("Usage: expand [STORE] ECL [--display|--codes|--count|--csv] [--json|--plain] [--config FILE]\n\nQuote ECL so the shell preserves operators, spaces and SNOMED terms.\n  --count    Return only the total\n  --display  Add each concept's term; the default in a terminal\n  --codes    Codes only, even in a terminal\n  --csv      A code,display table of every concept, for spreadsheets\n  --json     Emit code objects as JSONL, or a total object with --count\n\nExample: snomed-ecl-engine expand uk \"<< 404684003\" --csv > findings.csv\n\nA terminal lists the first 200 with the total; redirected output, --json\nand --csv return every concept. Member projections can return typed JSON\nrows; --count counts those rows, and --display and --csv need concepts.\nUse --config FILE for identifier-scheme and dialect aliases (docs/indexes.md).\nFor repeated queries, use batch to open the index once."),
        Some("search") => println!("Usage: search [STORE] TEXT [--within ECL] [--limit N] [--inactive]\n\nFind concepts whose terms contain every word, best match first. The last\nword may be the start of one, so \"chronic kid\" finds chronic kidney disease.\n  --within ECL  Search only the concepts an expression selects\n  --limit N     Show N matches (default 50)\n  --inactive    Include inactive concepts\n\nExample: snomed-ecl-engine search heart failure --within \"<< 404684003\""),
        Some("lookup" | "concept") => println!("Usage: lookup [STORE] SCTID\n\nDescribe one concept: its terms, status, parents, children, attribute\ngroups and reference set membership. `concept` is another name for it."),
        Some("history") => println!("Usage: history [STORE] SCTID\n\nShow a concept's historical associations: what replaced it, and what it\nreplaced, with the association for each."),
        Some("batch") => println!("Usage: batch [STORE] [--config FILE]\n\nRead one JSON object per line from stdin:\n  {{\"ecl\":\"<< 404684003\",\"count_only\":true}}\n\nEach response includes edition, query_config_sha256, total, parse_ms and eval_ms.\nOmit count_only or set it false to include codes as decimal strings.\nQuery errors return an error object; later queries still run.\nEnd stdin to exit. Output stays JSONL in terminals too."),
        Some("hierarchy") => println!("Usage: hierarchy [STORE] OPERATOR SCTID [--display]\n\nOperators: <  <<  <!  <<!  >  >>  >!  >>!\nQuote the operator. Output is code lines, or JSONL with --display.\nUse expand for full expressions and terminal display tables."),
        Some(_) => anyhow::bail!("Unknown command. Run --help for available commands"),
    }
    Ok(())
}

/// A JSON string field, cleaned for the terminal, or a stand-in when absent.
fn text(value: &serde_json::Value, key: &str) -> String {
    value[key]
        .as_str()
        .map_or_else(|| "(no display)".into(), clean)
}

/// A concept as `CODE  label`, marking an inactive one.
fn concept_line(value: &serde_json::Value) -> String {
    let inactive = value["active"].as_bool() == Some(false);
    format!(
        "{:<18}  {}{}",
        value["code"].as_str().unwrap_or(""),
        text(value, "display"),
        if inactive { "  (inactive)" } else { "" }
    )
}

/// Lists concepts under a heading, at most `limit` of them.
fn concept_list(title: &str, concepts: &[serde_json::Value], limit: usize) {
    println!("\n  {title} ({})", number(concepts.len()));
    for concept in concepts.iter().take(limit) {
        println!("    {}", concept_line(concept));
    }
    if concepts.len() > limit {
        println!("    ... {} more", number(concepts.len() - limit));
    }
}

pub fn search(value: &serde_json::Value, query: &str) {
    let concepts = value["concepts"].as_array().cloned().unwrap_or_default();
    let total = value["total"].as_u64().unwrap_or(0) as usize;
    println!("{}\n", heading("SNOMED ECL / search"));
    println!(
        "  \"{}\": {} matching concepts, best {} shown\n",
        clean(query),
        number(total),
        number(concepts.len())
    );
    for concept in &concepts {
        println!("  {}", concept_line(concept));
    }
    if concepts.is_empty() {
        println!("  Nothing matches every word. Try fewer or shorter words.");
    } else {
        println!("\n  `lookup CODE` describes one; --limit N shows more.");
    }
}

pub fn concept(value: &serde_json::Value) {
    println!("{}\n", heading("SNOMED ECL / concept"));
    println!(
        "  {}  {}",
        value["code"].as_str().unwrap_or(""),
        text(value, "display")
    );
    if let Some(fsn) = value["fsn"].as_str() {
        println!("  {:<18}  {}", "", clean(fsn));
    }
    let status = format!(
        "{}, {}",
        if value["active"].as_bool() == Some(true) {
            "active"
        } else {
            "inactive"
        },
        if value["defined"].as_bool() == Some(true) {
            "fully defined"
        } else {
            "primitive"
        }
    );
    println!("\n  Status    {status}");
    println!("  Module    {}", text(&value["module"], "display"));
    let empty = Vec::new();
    let list = |key: &str| value[key].as_array().unwrap_or(&empty).clone();
    concept_list("Parents", &list("parents"), 12);
    concept_list("Children", &list("children"), 12);
    let groups = list("groups");
    if !groups.is_empty() {
        println!("\n  Attributes");
        for group in &groups {
            let number = group["group"].as_u64().unwrap_or(0);
            let label = if number == 0 {
                "ungrouped".to_owned()
            } else {
                format!("group {number}")
            };
            println!("    {label}");
            for attribute in group["attributes"].as_array().unwrap_or(&empty) {
                let target = if attribute["target"].is_object() {
                    format!(
                        "{} ({})",
                        text(&attribute["target"], "display"),
                        attribute["target"]["code"].as_str().unwrap_or("")
                    )
                } else {
                    // ECL syntax: #number, "string", or true and false.
                    let literal = &attribute["value"]["value"];
                    match (attribute["value"]["type"].as_str(), literal.as_str()) {
                        (Some("number"), Some(number)) => format!("#{number}"),
                        (Some("text"), Some(text)) => format!("\"{}\"", clean(text)),
                        _ => literal.to_string(),
                    }
                };
                println!(
                    "      {} = {}",
                    text(&attribute["type"], "display"),
                    target.trim_end()
                );
            }
        }
    }
    let synonyms: Vec<_> = list("descriptions")
        .into_iter()
        .filter(|d| d["active"].as_bool() == Some(true))
        .collect();
    if !synonyms.is_empty() {
        println!("\n  Descriptions ({})", number(synonyms.len()));
        for description in synonyms.iter().take(12) {
            let preferred = description["preferred_in"]
                .as_array()
                .is_some_and(|p| !p.is_empty());
            println!(
                "    {} {}",
                if preferred { "*" } else { " " },
                clean(description["term"].as_str().unwrap_or(""))
            );
        }
        if synonyms.len() > 12 {
            println!("      ... {} more", number(synonyms.len() - 12));
        }
        println!("    * preferred in at least one language reference set");
    }
    concept_list("Reference sets", &list("refsets"), 8);
}

pub fn history(value: &serde_json::Value) {
    println!("{}\n", heading("SNOMED ECL / history"));
    println!(
        "  {}  {}  ({})",
        value["concept"].as_str().unwrap_or(""),
        text(value, "display"),
        if value["active"].as_bool() == Some(true) {
            "active"
        } else {
            "inactive"
        }
    );
    for (title, key) in [("Replaced by", "successors"), ("Replaces", "predecessors")] {
        let rows = value[key].as_array().cloned().unwrap_or_default();
        println!("\n  {title} ({})", number(rows.len()));
        for row in rows.iter().take(20) {
            println!(
                "    {}  [{}]",
                concept_line(&row["concept"]),
                text(&row["association"], "display")
            );
        }
        if rows.len() > 20 {
            println!("    ... {} more", number(rows.len() - 20));
        }
    }
}

/// Describes a result's size with the unit its projection returned.
pub fn total(result: &snomed_ecl_engine::eval::QueryResult) -> String {
    use snomed_ecl_engine::eval::QueryResult;
    let unit = match result {
        QueryResult::Concepts(_) => "concepts",
        QueryResult::Values(_) => "values",
        QueryResult::Rows(_) => "rows",
    };
    format!("{} {unit}", number(result.len()))
}