Skip to main content

dimvexa_cli/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! The command-line front end of dimvexa.
3//!
4//! ```text
5//! dimvexa convert VALUE FROM TO [--exact] [--json] [--config NAME_OR_PATH]... [--density NAME_OR_VALUE]
6//! dimvexa VALUE FROM TO [--exact] [--json]        shorthand
7//! dimvexa units [--groups | GROUP | UNIT] [--json]  list units by quantity, one group, or one unit
8//! dimvexa configs [NAME_OR_PATH] [--json]          what the shipped configs (or a file) contain
9//! ```
10//!
11//! This crate contains **no conversion logic**: it parses arguments, calls the public
12//! library API and formats the result, so it cannot disagree with the library. It always
13//! uses the exact path; the decimal it prints is the exact result rounded once to the
14//! nearest `f64` (0 ulp), never the fast path's result. The library's fast path is within
15//! 1 ulp (linear) or 2 ulp (affine) of that value (`docs/precision.md`).
16//!
17//! Exit codes: `0` success, `1` conversion refused or invalid input, `2` usage error.
18//! With `--json`, errors are printed to stdout as JSON (schema `dimvexa.convert/3`, see
19//! `docs/schemas/`; `units --json` uses `dimvexa.units/4`, `configs --json` `dimvexa.configs/1`) and the exit code stays non-zero. The error `code` is one of the
20//! library's stable codes ([`dimvexa::Error::ALL_CODES`]) or `usage`.
21#![forbid(unsafe_code)]
22#![doc(
23    html_logo_url = "https://raw.githubusercontent.com/welworx/dimvexa/main/docs/brand/icon-256.png",
24    html_favicon_url = "https://raw.githubusercontent.com/welworx/dimvexa/main/docs/brand/icon-32.png"
25)]
26
27use std::fmt::Write as _;
28use std::io::Write;
29
30use dimvexa::{Conversion, Dim, Error, Ratio, Registry, Unit, UnitExpr, registry};
31use dimvexa_config::{Config, ConfigUnit, Constant, Mapping};
32
33mod update;
34
35const USAGE: &str = "\
36Usage:
37  dimvexa convert VALUE FROM TO [--exact] [--json] [--config NAME_OR_PATH]... [--density NAME_OR_VALUE]
38  dimvexa VALUE FROM TO [--exact] [--json] [--config NAME_OR_PATH]... [--density NAME_OR_VALUE]
39  dimvexa units [--groups | GROUP | UNIT] [--json] [--config NAME_OR_PATH]...
40  dimvexa configs [NAME_OR_PATH] [--json]
41  dimvexa update [--check] [-y]
42
43Converts VALUE from unit FROM to unit TO.
44
45Commands:
46  units            list the supported units grouped by quantity, and the ambiguous symbols
47                   that are refused
48  units --groups   list only the groups (length, mass, volume, ...) with their sizes
49  units GROUP      list one group, for example: dimvexa units volume
50  units UNIT       show the details of one unit, for example: dimvexa units psi
51  configs          list the configs that ship with dimvexa (us, gb, ...)
52  configs NAME     show what one config contains: names, units and densities with their
53                   conditions and sources; a path shows your own file
54  update           replace this binary with the latest GitHub release (needs curl and tar;
55                   checks the published SHA-256 first; see docs/adr/015-update-command.md)
56  update --check   only report whether a newer release exists; exit 0 up to date, 3 newer
57                   release available, 1 error
58  update -y        do not ask \"Update to vX.Y.Z? [y/N]\" first (also --yes)
59
60Options:
61  --exact      print the exact result and its decimal: a fraction, with pi or a power of ten when
62               the value has one (`1/2 pi = 1.5707963267948966`); a whole number is printed once
63  --json       machine-readable output (schema dimvexa.convert/3; dimvexa.units/4 for units,
64               dimvexa.configs/1 for configs)
65  --color WHEN auto (default: a terminal, unless NO_COLOR is set), always (also
66               CLICOLOR_FORCE=1) or never; JSON is never coloured
67  --pretty     --json, indented for reading (same fields, same order)
68  --config NAME_OR_PATH  (beta) apply a config (repeatable, later ones win; see
69               docs/adr/014-one-config-mechanism.md). A shipped one is named (us, gb,
70               metric-trade, year-julian, year-civil, water); a file has a path (./mine.toml,
71               or anything with a / or ending in .toml). Without --config the names stay strict
72  --density NAME_OR_VALUE  use a density for mass <-> volume: a name from a loaded config, for
73               example --config water --density water_25C, or your own number, for example
74               --density 998.2067kg/m3. The assumption is printed with the result
75  --version    print version and data table version
76  -h, --help   print this help
77
78Examples:
79  dimvexa convert 10 m/s km/h
80  dimvexa convert 1 psi Pa --exact
81  dimvexa convert 100 degC degF --json
82  dimvexa convert 100 degC degF --pretty
83  dimvexa convert 1 ton kg --config us
84  dimvexa convert 1 mt kg --config metric-trade
85  dimvexa convert 1 L kg --config water --density water_25C
86  dimvexa convert 1 L kg --density 998.2067kg/m3
87  dimvexa convert 1 bbl_oil L --config oil.toml
88
89Precision: the CLI always uses the exact path, so the number printed is the exact result
90rounded once to the nearest f64 (0 ulp). The library's fast f64 path is within 1 ulp (linear
91units) or 2 ulp (temperature scales such as degC and degF) of it; see docs/precision.md.
92
93Exit codes: 0 success, 1 conversion refused or invalid input, 2 usage error; `update --check`
94also exits 3 when a newer release exists.
95";
96
97/// Shown after the project facts; the long form is `docs/licensing.md`.
98const DISCLAIMER: &str = "\
99Provided as is, without warranty. Unit factors and sources can contain errors: verify any
100result you depend on against the primary source, and do not use it as the only safeguard in
101safety-critical, medical, legal, financial or custody-transfer settings. Independent project,
102not affiliated with other unit libraries. Terms: LICENSE-MIT and LICENSE-APACHE.";
103
104/// The value of `--color`.
105#[derive(Clone, Copy, Debug, PartialEq, Eq)]
106enum ColorWhen {
107    Auto,
108    Always,
109    Never,
110}
111
112/// Removes `--color WHEN` / `--color=WHEN` from `args`; the last one wins.
113fn take_color_option(args: &mut Vec<String>) -> Result<Option<ColorWhen>, String> {
114    let mut found = None;
115    let mut rest = Vec::with_capacity(args.len());
116    let mut it = std::mem::take(args).into_iter();
117    while let Some(a) = it.next() {
118        let value = if a == "--color" {
119            it.next().filter(|v| !v.starts_with("--"))
120        } else if let Some(v) = a.strip_prefix("--color=") {
121            Some(v.to_string())
122        } else {
123            rest.push(a);
124            continue;
125        };
126        found = Some(match value.as_deref() {
127            Some("auto") => ColorWhen::Auto,
128            Some("always") => ColorWhen::Always,
129            Some("never") => ColorWhen::Never,
130            _ => {
131                *args = rest;
132                return Err("'--color' needs auto, always or never (--color=always)".to_string());
133            }
134        });
135    }
136    *args = rest;
137    Ok(found)
138}
139
140/// Name, version, tagline and where the project lives, from the package metadata.
141fn about() -> String {
142    format!(
143        "License:    {}\nAuthors:    {}\nRepository: {}\n\n{DISCLAIMER}\n",
144        env!("CARGO_PKG_LICENSE"),
145        env!("CARGO_PKG_AUTHORS"),
146        env!("CARGO_PKG_REPOSITORY")
147    )
148}
149
150/// The text of `--help`: a title line, the usage, and the project facts.
151fn help_text() -> String {
152    format!(
153        "dimvexa {} - Exact factors. Loud failures. Zero guessing.\n\n{USAGE}\n{}",
154        env!("CARGO_PKG_VERSION"),
155        about()
156    )
157}
158
159/// Whether each output stream may carry ANSI colour. Off unless the caller turns it on
160/// (the binary does so for a terminal, honouring `NO_COLOR`).
161#[derive(Clone, Copy, Debug, Default)]
162pub struct Color {
163    /// Colour the result on stdout.
164    pub out: bool,
165    /// Colour `error`, `hint` and `note` lines on stderr.
166    pub err: bool,
167}
168
169const BOLD_GREEN: &str = "\x1b[1;32m";
170const CYAN: &str = "\x1b[36m";
171const BOLD_RED: &str = "\x1b[1;31m";
172const YELLOW: &str = "\x1b[33m";
173const RESET: &str = "\x1b[0m";
174
175/// Colours the leading label of `error[..]:`, `hint:` and `note:` lines.
176fn colorize_diagnostics(text: &str) -> String {
177    let mut out = String::with_capacity(text.len());
178    for line in text.split_inclusive('\n') {
179        let split = if line.starts_with("error[") {
180            line.find("]:").map(|i| (i.saturating_add(1), BOLD_RED))
181        } else if line.starts_with("hint:") {
182            Some((5, CYAN))
183        } else if line.starts_with("note:") {
184            Some((5, YELLOW))
185        } else {
186            None
187        };
188        match split.and_then(|(n, c)| Some((line.get(..n)?, line.get(n..)?, c))) {
189            Some((label, rest, c)) => {
190                let _ = write!(out, "{c}{label}{RESET}{rest}");
191            }
192            None => out.push_str(line),
193        }
194    }
195    out
196}
197
198/// A JSON value for re-indenting: scalars are kept as their source text.
199enum Node {
200    Scalar(String),
201    Array(Vec<Self>),
202    Object(Vec<(String, Self)>),
203}
204
205/// Reads one value of the compact JSON that this crate writes. `None` if it is not JSON.
206fn read_node(chars: &[char], i: &mut usize) -> Option<Node> {
207    fn string(chars: &[char], i: &mut usize) -> Option<String> {
208        let start = *i;
209        *i = i.checked_add(1)?;
210        while let Some(&c) = chars.get(*i) {
211            *i = i.checked_add(1)?;
212            match c {
213                '\\' => *i = i.checked_add(1)?,
214                '"' => return Some(chars.get(start..*i)?.iter().collect()),
215                _ => {}
216            }
217        }
218        None
219    }
220    match *chars.get(*i)? {
221        '"' => string(chars, i).map(Node::Scalar),
222        open @ ('[' | '{') => {
223            *i = i.checked_add(1)?;
224            let close = if open == '[' { ']' } else { '}' };
225            let (mut items, mut fields) = (Vec::new(), Vec::new());
226            if chars.get(*i) == Some(&close) {
227                *i = i.checked_add(1)?;
228            } else {
229                loop {
230                    if open == '{' {
231                        let key = string(chars, i)?;
232                        if chars.get(*i) != Some(&':') {
233                            return None;
234                        }
235                        *i = i.checked_add(1)?;
236                        fields.push((key, read_node(chars, i)?));
237                    } else {
238                        items.push(read_node(chars, i)?);
239                    }
240                    let c = *chars.get(*i)?;
241                    *i = i.checked_add(1)?;
242                    if c == close {
243                        break;
244                    }
245                    if c != ',' {
246                        return None;
247                    }
248                }
249            }
250            Some(if open == '[' {
251                Node::Array(items)
252            } else {
253                Node::Object(fields)
254            })
255        }
256        _ => {
257            let start = *i;
258            while chars.get(*i).is_some_and(|c| !matches!(c, ',' | ']' | '}')) {
259                *i = i.checked_add(1)?;
260            }
261            Some(Node::Scalar(chars.get(start..*i)?.iter().collect()))
262        }
263    }
264}
265
266/// An array of scalars stays on one line while it is short.
267fn fits_on_a_line(items: &[Node]) -> bool {
268    let mut width = 0usize;
269    for n in items {
270        match n {
271            Node::Scalar(s) => width = width.saturating_add(s.chars().count()).saturating_add(2),
272            _ => return false,
273        }
274    }
275    width <= 60
276}
277
278/// Writes `node` indented by two spaces per level. Short arrays of scalars stay on one line.
279fn write_node(node: &Node, depth: usize, out: &mut String) {
280    let pad = |n: usize| "  ".repeat(n);
281    match node {
282        Node::Scalar(s) => out.push_str(s),
283        Node::Array(items) if fits_on_a_line(items) => {
284            out.push('[');
285            for (k, n) in items.iter().enumerate() {
286                if k > 0 {
287                    out.push_str(", ");
288                }
289                write_node(n, depth, out);
290            }
291            out.push(']');
292        }
293        Node::Array(items) => {
294            out.push_str("[\n");
295            for (k, n) in items.iter().enumerate() {
296                out.push_str(&pad(depth.saturating_add(1)));
297                write_node(n, depth.saturating_add(1), out);
298                out.push_str(if k.saturating_add(1) < items.len() {
299                    ",\n"
300                } else {
301                    "\n"
302                });
303            }
304            out.push_str(&pad(depth));
305            out.push(']');
306        }
307        Node::Object(fields) if fields.is_empty() => out.push_str("{}"),
308        Node::Object(fields) => {
309            out.push_str("{\n");
310            for (k, (key, n)) in fields.iter().enumerate() {
311                out.push_str(&pad(depth.saturating_add(1)));
312                let _ = write!(out, "{key}: ");
313                write_node(n, depth.saturating_add(1), out);
314                out.push_str(if k.saturating_add(1) < fields.len() {
315                    ",\n"
316                } else {
317                    "\n"
318                });
319            }
320            out.push_str(&pad(depth));
321            out.push('}');
322        }
323    }
324}
325
326/// Re-indents one line of compact JSON (`--pretty`); anything that is not JSON is returned as is.
327fn pretty_json(line: &str) -> String {
328    let chars: Vec<char> = line.chars().collect();
329    let mut i = 0;
330    match read_node(&chars, &mut i) {
331        Some(node) if i == chars.len() => {
332            let mut out = String::new();
333            write_node(&node, 0, &mut out);
334            out
335        }
336        _ => line.to_string(),
337    }
338}
339
340/// Parsed command line.
341struct Args {
342    /// The density named or written with `--density`, if any.
343    density: Option<String>,
344    value: String,
345    from: String,
346    to: String,
347    exact: bool,
348    json: bool,
349}
350
351/// What `dimvexa units` was asked for.
352enum UnitsTarget {
353    /// The whole table, grouped.
354    All,
355    /// Only the names of the groups.
356    Groups,
357    /// One group (`volume`) or one unit (`psi`, `km`).
358    Query(String),
359}
360
361enum Parsed {
362    Convert(Args),
363    Units {
364        target: UnitsTarget,
365        json: bool,
366    },
367    /// `dimvexa configs [NAME_OR_PATH]`.
368    Configs {
369        target: Option<String>,
370        json: bool,
371    },
372    /// `dimvexa update [--check] [--yes]`.
373    Update {
374        check: bool,
375        yes: bool,
376    },
377    Help,
378    Version,
379}
380
381/// Why the command line could not be used.
382struct UsageError {
383    message: String,
384    json: bool,
385    /// JSON schema id of the failed command (`convert`, `units` or `configs`).
386    schema: &'static str,
387    suggestions: Vec<String>,
388}
389
390/// The JSON schema name of the command the line asks for: the first word that is not an option.
391fn command_schema(args: &[String]) -> &'static str {
392    match args
393        .iter()
394        .find(|a| !a.starts_with('-'))
395        .map(String::as_str)
396    {
397        Some("units") => "units",
398        Some("configs") => "configs",
399        _ => "convert",
400    }
401}
402
403/// The options that take a value, split off the command line before the rest is read.
404struct ValueOptions {
405    configs: Vec<String>,
406    density: Option<String>,
407}
408
409/// Splits `--config NAME_OR_PATH` (repeatable) and `--density NAME_OR_VALUE`, also as
410/// `--opt=value`.
411fn split_options(args: &[String]) -> Result<(ValueOptions, Vec<String>), UsageError> {
412    let mut opts = ValueOptions {
413        configs: Vec::new(),
414        density: None,
415    };
416    let mut rest = Vec::new();
417    let missing = |what: &str| UsageError {
418        message: format!("'{what}' needs a value"),
419        json: args.iter().any(|x| x == "--json"),
420        schema: command_schema(args),
421        suggestions: Vec::new(),
422    };
423    let mut it = args.iter();
424    while let Some(a) = it.next() {
425        let (name, inline) = match a.split_once('=') {
426            Some((n @ ("--config" | "--density"), v)) => (n, Some(v.to_string())),
427            _ => (a.as_str(), None),
428        };
429        match name {
430            "--bridge" => {
431                return Err(UsageError {
432                    message:
433                        "'--bridge' was renamed '--density' (ADR-014): use --density NAME_OR_VALUE"
434                            .to_string(),
435                    ..missing(name)
436                });
437            }
438            "--config" | "--density" => {
439                let value = match inline {
440                    Some(v) => v,
441                    // The next word must be a value, not another option (`--config --json`).
442                    None => it
443                        .next()
444                        .filter(|v| !v.starts_with("--"))
445                        .cloned()
446                        .ok_or_else(|| missing(name))?,
447                };
448                if name == "--config" {
449                    opts.configs.push(value);
450                } else if opts.density.replace(value).is_some() {
451                    return Err(UsageError {
452                        message: "'--density' is given twice; give one density".to_string(),
453                        ..missing(name)
454                    });
455                }
456            }
457            _ => rest.push(a.clone()),
458        }
459    }
460    Ok((opts, rest))
461}
462
463/// The rest of `dimvexa units ...` after the command word.
464fn parse_units(
465    rest: &[&str],
466    want_groups: bool,
467    json: bool,
468    configs: Vec<String>,
469    usage: &dyn Fn(String) -> UsageError,
470) -> Result<(Parsed, Vec<String>), UsageError> {
471    match (rest, want_groups) {
472        ([], false) => Ok((
473            Parsed::Units {
474                target: UnitsTarget::All,
475                json,
476            },
477            configs,
478        )),
479        ([], true) => Ok((
480            Parsed::Units {
481                target: UnitsTarget::Groups,
482                json,
483            },
484            configs,
485        )),
486        ([_], true) => Err(usage(
487            "'--groups' lists the groups and takes no GROUP or UNIT".to_string(),
488        )),
489        ([query], false) => Ok((
490            Parsed::Units {
491                target: UnitsTarget::Query((*query).to_string()),
492                json,
493            },
494            configs,
495        )),
496        _ => Err(usage(format!(
497            "'units' takes at most one argument (a group such as 'length' or a unit such as 'psi'), got {}",
498            rest.len()
499        ))),
500    }
501}
502
503/// The rest of `dimvexa configs ...` after the command word.
504fn parse_configs(
505    rest: &[&str],
506    json: bool,
507    configs: Vec<String>,
508    usage: &dyn Fn(String) -> UsageError,
509) -> Result<(Parsed, Vec<String>), UsageError> {
510    match rest {
511        [] => Ok((Parsed::Configs { target: None, json }, configs)),
512        [one] => Ok((
513            Parsed::Configs {
514                target: Some((*one).to_string()),
515                json,
516            },
517            configs,
518        )),
519        _ => Err(usage(format!(
520            "'configs' takes at most one argument (a name such as 'us' or a path), got {}",
521            rest.len()
522        ))),
523    }
524}
525
526/// Checks what the command line says next to `update`: `extra` is a positional argument,
527/// `--exact`, `--json` or `--groups`; `value_options` is `--config` or `--density`. A refusal is
528/// never a `--json` error: the update has no JSON output and no schema.
529fn check_update_args(
530    extra: bool,
531    value_options: bool,
532    schema: &'static str,
533) -> Result<(), UsageError> {
534    let plain = |message: &str| UsageError {
535        message: message.to_string(),
536        json: false,
537        schema,
538        suggestions: Vec::new(),
539    };
540    if extra {
541        return Err(plain("'update' takes only --check and -y"));
542    }
543    if value_options {
544        return Err(plain("'update' does not take --config or --density"));
545    }
546    Ok(())
547}
548
549/// Forgives how people write a conversion: a degree sign set apart from its scale
550/// (`° F` is `°F`) and a filler `to` (`0 °C to °F` is `0 °C °F`).
551fn normalize_convert(words: &[&str]) -> Vec<String> {
552    let mut out: Vec<String> = Vec::with_capacity(words.len());
553    let mut i = 0;
554    while let Some(&w) = words.get(i) {
555        i = i.saturating_add(1);
556        if matches!(w, "°" | "º") {
557            if let Some(&scale) = words.get(i).filter(|n| matches!(**n, "C" | "F" | "R")) {
558                out.push(format!("°{scale}"));
559                i = i.saturating_add(1);
560                continue;
561            }
562        }
563        out.push(w.to_string());
564    }
565    if let [value, from, to, dest] = out.as_slice() {
566        if to == "to" {
567            return vec![value.clone(), from.clone(), dest.clone()];
568        }
569    }
570    out
571}
572
573fn parse_args(all: &[String]) -> Result<(Parsed, Vec<String>), UsageError> {
574    let (opts, args) = split_options(all)?;
575    let configs = opts.configs;
576    let args = args.as_slice();
577    let json = args.iter().any(|a| a == "--json");
578    let schema = command_schema(args);
579    let usage = |message: String| UsageError {
580        message,
581        json,
582        schema,
583        suggestions: Vec::new(),
584    };
585    let mut positional: Vec<&str> = Vec::new();
586    let (mut exact, mut want_json, mut want_groups) = (false, false, false);
587    let (mut want_check, mut want_yes) = (false, false);
588    for a in args {
589        match a.as_str() {
590            "--check" => want_check = true,
591            "-y" | "--yes" => want_yes = true,
592            "--exact" => exact = true,
593            "--json" => want_json = true,
594            "--groups" => want_groups = true,
595            "-h" | "--help" => return Ok((Parsed::Help, configs)),
596            "-V" | "--version" => return Ok((Parsed::Version, configs)),
597            s if s.starts_with("--") => return Err(usage(format!("unknown option '{s}'"))),
598            s => positional.push(s),
599        }
600    }
601    if let Some((&"update", rest)) = positional.split_first() {
602        check_update_args(
603            !rest.is_empty() || exact || want_json || want_groups,
604            !configs.is_empty() || opts.density.is_some(),
605            schema,
606        )?;
607        return Ok((
608            Parsed::Update {
609                check: want_check,
610                yes: want_yes,
611            },
612            configs,
613        ));
614    }
615    if want_check || want_yes {
616        return Err(usage(
617            "'--check' and '-y' only apply to 'update'".to_string(),
618        ));
619    }
620    if let Some((&"units", rest)) = positional.split_first() {
621        if exact {
622            return Err(usage("'units' does not take --exact".to_string()));
623        }
624        if opts.density.is_some() {
625            return Err(usage(
626                "'--density' selects a density for a conversion; 'units' lists them".to_string(),
627            ));
628        }
629        return parse_units(rest, want_groups, want_json, configs, &usage);
630    }
631    if let Some((&"configs", rest)) = positional.split_first() {
632        if exact || want_groups {
633            return Err(usage(
634                "'configs' takes only NAME_OR_PATH and --json".to_string(),
635            ));
636        }
637        if !configs.is_empty() || opts.density.is_some() {
638            return Err(usage(
639                "'configs' shows configs by name or path; it does not take --config or --density"
640                    .to_string(),
641            ));
642        }
643        return parse_configs(rest, want_json, configs, &usage);
644    }
645    if want_groups {
646        return Err(usage("'--groups' only applies to 'units'".to_string()));
647    }
648    let density = opts.density;
649    if positional.first() == Some(&"convert") {
650        positional.remove(0);
651    }
652    let positional = normalize_convert(&positional);
653    match positional.as_slice() {
654        [value, from, to] => Ok((
655            Parsed::Convert(Args {
656                density,
657                value: value.clone(),
658                from: from.clone(),
659                to: to.clone(),
660                exact,
661                json: want_json,
662            }),
663            configs,
664        )),
665        other => Err(usage(format!(
666            "expected VALUE FROM TO, got {} argument(s); try 'dimvexa --help'",
667            other.len()
668        ))),
669    }
670}
671
672/// Escapes a string for JSON.
673fn json_string(s: &str) -> String {
674    let mut out = String::with_capacity(s.len().saturating_add(2));
675    out.push('"');
676    for c in s.chars() {
677        match c {
678            '"' => out.push_str("\\\""),
679            '\\' => out.push_str("\\\\"),
680            '\n' => out.push_str("\\n"),
681            '\r' => out.push_str("\\r"),
682            '\t' => out.push_str("\\t"),
683            c if u32::from(c) < 0x20 => {
684                let _ = write!(out, "\\u{:04x}", u32::from(c));
685            }
686            c => out.push(c),
687        }
688    }
689    out.push('"');
690    out
691}
692
693/// Formats an `f64` as the shortest text that reads back, switching to exponent form for
694/// very large and very small magnitudes. The output is valid JSON as well.
695fn f64_text(x: f64) -> String {
696    let a = x.abs();
697    if a != 0.0 && !(1e-4..1e16).contains(&a) {
698        format!("{x:e}")
699    } else {
700        format!("{x}")
701    }
702}
703
704/// The JSON schema id of a command: `dimvexa.convert/3`, `dimvexa.units/4`, `dimvexa.configs/1`.
705fn schema_id(command: &str) -> String {
706    let version = match command {
707        "configs" => 1,
708        "units" => 4,
709        _ => 3,
710    };
711    format!("{}.{command}/{version}", dimvexa::PROJECT_NAME)
712}
713
714fn json_error(
715    schema: &str,
716    code: &str,
717    message: &str,
718    candidates: &[String],
719    suggestions: &[String],
720) -> String {
721    let mut s = format!(
722        "{{\"schema\":{},\"ok\":false,\"error\":{{\"code\":{},\"message\":{}",
723        json_string(&schema_id(schema)),
724        json_string(code),
725        json_string(message)
726    );
727    if !candidates.is_empty() {
728        let list: Vec<String> = candidates.iter().map(|c| json_string(c)).collect();
729        let _ = write!(s, ",\"candidates\":[{}]", list.join(","));
730    }
731    if !suggestions.is_empty() {
732        let list: Vec<String> = suggestions.iter().map(|c| json_string(c)).collect();
733        let _ = write!(s, ",\"suggestions\":[{}]", list.join(","));
734    }
735    s.push_str("}}");
736    s
737}
738
739/// A registry with the user's configs applied, and which configs those were.
740struct Loaded {
741    reg: Registry,
742    /// The name mappings in effect (empty without a config).
743    mappings: Vec<Mapping>,
744    /// The user units in effect (empty without a config).
745    units: Vec<ConfigUnit>,
746    /// The densities in effect (empty without a config).
747    densities: Vec<Constant>,
748    /// `(origin, content hash)` of each config, in the order given. The origin is the file
749    /// path, or `bundled:NAME` for a config that ships with dimvexa.
750    configs: Vec<(String, String)>,
751}
752
753/// Why the registry could not be built.
754enum LoadError {
755    /// A config file could not be read (a usage problem).
756    Io(String),
757    /// The table or a config was refused by the library.
758    Lib(Error),
759}
760
761/// The largest config file we read: a config is a handful of lines, so anything bigger is
762/// a mistake (or a device such as `/dev/zero`), not a config.
763const MAX_CONFIG_BYTES: u64 = 1 << 20;
764
765/// Reads a config file: a regular file of at most [`MAX_CONFIG_BYTES`], valid UTF-8.
766fn read_config(file: &str) -> Result<String, LoadError> {
767    use std::io::Read as _;
768    let fail = |why: String| LoadError::Io(format!("cannot read config '{file}': {why}"));
769    let meta = std::fs::metadata(file).map_err(|e| fail(e.to_string()))?;
770    if !meta.is_file() {
771        return Err(fail("not a regular file".to_string()));
772    }
773    let mut text = String::new();
774    std::fs::File::open(file)
775        .map_err(|e| fail(e.to_string()))?
776        .take(MAX_CONFIG_BYTES.saturating_add(1))
777        .read_to_string(&mut text)
778        .map_err(|e| fail(e.to_string()))?;
779    if u64::try_from(text.len()).map_or(true, |n| n > MAX_CONFIG_BYTES) {
780        return Err(fail(format!("larger than {MAX_CONFIG_BYTES} bytes")));
781    }
782    Ok(text)
783}
784
785/// Reads one `--config` value: a shipped config by name, or a file by path (ADR-014). A bare
786/// word is always a shipped name; a file called `us` is written `./us`.
787fn read_spec(spec: &str) -> Result<Config, LoadError> {
788    if dimvexa_config::is_path(spec) {
789        let text = read_config(spec)?;
790        return Config::parse(&text, spec).map_err(LoadError::Lib);
791    }
792    dimvexa_config::bundled(spec).map_or_else(
793        || {
794            Err(LoadError::Io(format!(
795                "no config named '{spec}'; shipped configs: {} (a file needs a path, for example ./{spec} or {spec}.toml)",
796                bundled_names()
797            )))
798        },
799        |b| b.config().map_err(LoadError::Lib),
800    )
801}
802
803fn bundled_names() -> String {
804    dimvexa_config::BUNDLED
805        .iter()
806        .map(|b| b.name)
807        .collect::<Vec<_>>()
808        .join(", ")
809}
810
811fn load(config_specs: &[String]) -> Result<Loaded, LoadError> {
812    let mut reg = registry().map_err(LoadError::Lib)?;
813    let (mut parsed, mut configs) = (Vec::new(), Vec::new());
814    for spec in config_specs {
815        let cfg = read_spec(spec)?;
816        configs.push((cfg.origin().to_string(), cfg.hash().to_string()));
817        parsed.push(cfg);
818    }
819    let applied = dimvexa_config::apply(&mut reg, &parsed).map_err(LoadError::Lib)?;
820    let densities = dimvexa_config::constants(&parsed);
821    Ok(Loaded {
822        reg,
823        mappings: applied.mappings,
824        units: applied.units,
825        densities,
826        configs,
827    })
828}
829
830/// Prints a load failure for `schema` and returns the exit code.
831fn report_load_error(
832    e: &LoadError,
833    json: bool,
834    schema: &str,
835    out: &mut dyn Write,
836    err: &mut dyn Write,
837) -> u8 {
838    let (code, message, status) = match e {
839        LoadError::Io(m) => ("usage", m.clone(), 2),
840        LoadError::Lib(e) => (e.code(), e.to_string(), 1),
841    };
842    if json {
843        let _ = writeln!(out, "{}", json_error(schema, code, &message, &[], &[]));
844    } else {
845        let _ = writeln!(err, "error[{code}]: {message}");
846    }
847    status
848}
849
850/// JSON array of the configs used: `[{"file":..,"hash":..}]`.
851fn configs_json(configs: &[(String, String)]) -> String {
852    let items: Vec<String> = configs
853        .iter()
854        .map(|(f, h)| {
855            format!(
856                "{{\"file\":{},\"hash\":{}}}",
857                json_string(f),
858                json_string(h)
859            )
860        })
861        .collect();
862    format!("[{}]", items.join(","))
863}
864
865/// Everything computed for a successful conversion, before formatting.
866struct Outcome<'a> {
867    args: &'a Args,
868    configs: &'a [(String, String)],
869    /// The density the conversion went through, if it needed one.
870    density: Option<&'a Constant>,
871    from: UnitExpr,
872    to: UnitExpr,
873    conv: Conversion,
874    exact: Ratio,
875    /// The exact result rounded once to the nearest `f64` (the CLI always uses the exact path).
876    approx: f64,
877}
878
879fn compute<'a>(
880    args: &'a Args,
881    loaded: &'a Loaded,
882    density: Option<&'a Constant>,
883) -> Result<Outcome<'a>, Error> {
884    let reg = &loaded.reg;
885    let value: Ratio = args.value.parse()?;
886    let from = reg.parse(&args.from)?;
887    let to = reg.parse(&args.to)?;
888    // A density is used only when asked for and only when the plain conversion is refused
889    // for lack of a density; otherwise the ordinary exact conversion stands.
890    let (conv, used) = match (Conversion::between(&from, &to), density) {
891        (Ok(conv), _) => (conv, None),
892        (Err(Error::DimensionMismatch { .. }), Some(d)) => (d.conversion(&from, &to)?, Some(d)),
893        (Err(e), _) => return Err(e),
894    };
895    let exact = conv.apply_exact(value)?;
896    let approx = exact.to_f64()?;
897    Ok(Outcome {
898        args,
899        configs: &loaded.configs,
900        density: used,
901        from,
902        to,
903        conv,
904        exact,
905        approx,
906    })
907}
908
909/// Why `--density` could not be resolved.
910enum DensityError {
911    /// A name that no loaded config defines, or a missing value: a usage problem, with the
912    /// known names.
913    Usage(String, Vec<String>),
914    /// The library refused the caller's own number (not a density, ambiguous unit, overflow).
915    Lib(Error),
916}
917
918/// Resolves `--density`: a number with a unit is the caller's own density, anything else is
919/// the name of a density in a loaded config.
920fn resolve_density(spec: &str, loaded: &Loaded) -> Result<Constant, DensityError> {
921    let starts_like_a_number = spec.starts_with(|c: char| c.is_ascii_digit() || "+-.".contains(c));
922    if starts_like_a_number {
923        return Constant::density_from_text(&loaded.reg, spec).map_err(DensityError::Lib);
924    }
925    let known: Vec<String> = loaded.densities.iter().map(|d| d.name.clone()).collect();
926    loaded
927        .densities
928        .iter()
929        .find(|d| d.name == spec)
930        .cloned()
931        .ok_or_else(|| {
932            let message = if known.is_empty() {
933                format!(
934                    "no density named '{spec}': no loaded config has a [[density]] (try --config water, or write your own: --density 998.2067kg/m3)"
935                )
936            } else {
937                format!("no density named '{spec}'; loaded densities: {}", known.join(", "))
938            };
939            DensityError::Usage(message, known)
940        })
941}
942
943fn render_text(o: &Outcome<'_>, color: bool) -> String {
944    let value = if o.args.exact {
945        // The symbolic exact value and the result rounded once to the nearest f64 (ADR-018).
946        // A whole number is its own decimal, so it is printed once, without "/1".
947        match o.exact.folded() {
948            Ok(w) if w.is_integer() && !w.has_exponents() => w.num().to_string(),
949            _ => format!("{} = {}", o.exact.to_fraction_string(), f64_text(o.approx)),
950        }
951    } else {
952        f64_text(o.approx)
953    };
954    if color {
955        format!("{BOLD_GREEN}{value}{RESET} {CYAN}{}{RESET}", o.to)
956    } else {
957        format!("{value} {}", o.to)
958    }
959}
960
961/// The JSON object describing a density: everything a reader needs to judge the assumption.
962fn density_json(b: &Constant) -> String {
963    format!(
964        "{{\"name\":{},\"kind\":{},\"value\":{},\"unit\":{},\"conditions\":{},\"source\":{},\"file\":{},\"line\":{}}}",
965        json_string(&b.name),
966        json_string(b.kind.name()),
967        json_string(&b.value.to_fraction_string()),
968        json_string(b.kind.unit()),
969        json_string(&b.conditions),
970        json_string(&b.source),
971        json_string(&b.origin),
972        b.line
973    )
974}
975
976/// One line for people: the assumption behind a result that used a density. Text from the config has its
977/// control characters replaced, and a density that is not a short decimal is shown as the
978/// exact fraction, so the note never reads as a different number.
979fn density_note(b: &Constant) -> String {
980    let value = match b.value.to_decimal(15) {
981        Ok((decimal, true)) => decimal,
982        Ok((decimal, false)) => format!("{} (about {decimal})", b.value.to_fraction_string()),
983        Err(_) => b.value.to_fraction_string(),
984    };
985    let clean = |s: &str| -> String {
986        s.chars()
987            .map(|c| if c.is_control() { ' ' } else { c })
988            .collect()
989    };
990    let at = if b.line == 0 {
991        clean(&b.origin)
992    } else {
993        format!("{}:{}", clean(&b.origin), b.line)
994    };
995    format!(
996        "note: used density {} ({} {}; {}; source: {}; {})",
997        clean(&b.name),
998        value,
999        b.kind.unit(),
1000        clean(&b.conditions),
1001        clean(&b.source),
1002        at
1003    )
1004}
1005
1006fn render_json(o: &Outcome<'_>) -> Result<String, Error> {
1007    // A value with pi has no finite expansion: the decimal is then the result rounded once to
1008    // the nearest f64, and `truncated` says it is not the whole value.
1009    // The same fallback applies to a power of ten that does not fit the fraction.
1010    let (decimal, complete) = match o.exact.to_decimal(15) {
1011        Ok(d) => d,
1012        Err(_) if o.exact.pi_exp() != 0 || o.exact.ten_exp() != 0 => (f64_text(o.approx), false),
1013        Err(e) => return Err(e),
1014    };
1015    let dims: Vec<String> =
1016        o.to.dim()
1017            .exponents()
1018            .iter()
1019            .map(ToString::to_string)
1020            .collect();
1021    let mut sources: Vec<String> = o.from.sources().to_vec();
1022    for s in o.to.sources() {
1023        if !sources.contains(s) {
1024            sources.push(s.clone());
1025        }
1026    }
1027    if let Some(b) = o.density {
1028        sources.push(format!("{} (density {})", b.source, b.name));
1029    }
1030    let sources: Vec<String> = sources.iter().map(|s| json_string(s)).collect();
1031    let exactness = o.density.map_or_else(
1032        || o.from.exactness().combine(o.to.exactness()),
1033        |b| {
1034            o.from
1035                .exactness()
1036                .combine(o.to.exactness())
1037                .combine(b.exactness())
1038        },
1039    );
1040    let packs: Vec<String> = dimvexa::PACKS.iter().map(|p| json_string(p)).collect();
1041    Ok(format!(
1042        "{{\"schema\":{},\"ok\":true,\
1043\"input\":{{\"value\":{},\"unit\":{}}},\
1044\"output\":{{\"value\":{},\"unit\":{},\"f64\":{},\"truncated\":{}}},\
1045\"exact\":{{\"value\":{},\"decimal\":{},\"factor\":{},\"offset\":{}}},\
1046\"exactness\":{},\"dimension\":[{}],\"kind\":{},\"quantity_kind\":{},\
1047\"packs\":[{}],\"data_version\":{},\"sources\":[{}],\"config\":{},\"density\":{}}}",
1048        json_string(&schema_id("convert")),
1049        json_string(&o.args.value),
1050        json_string(&o.from.to_string()),
1051        json_string(&decimal),
1052        json_string(&o.to.to_string()),
1053        f64_text(o.approx),
1054        !complete,
1055        json_string(&o.exact.to_fraction_string()),
1056        json_string(&f64_text(o.approx)),
1057        json_string(&o.conv.factor().to_fraction_string()),
1058        json_string(&o.conv.offset().to_fraction_string()),
1059        json_string(exactness.name()),
1060        dims.join(","),
1061        json_string(o.conv.shape().name()),
1062        json_string(o.to.kind().name()),
1063        packs.join(","),
1064        json_string(&dimvexa::data_version()),
1065        sources.join(","),
1066        configs_json(o.configs),
1067        o.density.map_or_else(|| "null".to_string(), density_json),
1068    ))
1069}
1070
1071/// Quantity group of a dimension (`[kg, m, s, A, K, mol, cd]` exponents), for listing.
1072/// Presentation only: it never takes part in a conversion.
1073fn group_name(dim: Dim) -> String {
1074    let name = match dim.exponents() {
1075        [0, 1, 0, 0, 0, 0, 0] => "length",
1076        [1, 0, 0, 0, 0, 0, 0] => "mass",
1077        [0, 0, 1, 0, 0, 0, 0] => "time",
1078        [0, 0, 0, 1, 0, 0, 0] => "electric_current",
1079        [0, 0, 0, 0, 1, 0, 0] => "temperature",
1080        [0, 0, 0, 0, 0, 1, 0] => "amount_of_substance",
1081        [0, 0, 0, 0, 0, 0, 1] => "luminous_intensity",
1082        [0, 0, 0, 0, 0, 0, 0] => "angle",
1083        [0, -2, 0, 0, 0, 0, 1] => "illuminance",
1084        [0, 0, -1, 0, 0, 1, 0] => "catalytic_activity",
1085        [0, 2, -2, 0, 0, 0, 0] => "absorbed_dose",
1086        [0, 2, 0, 0, 0, 0, 0] => "area",
1087        [0, 3, 0, 0, 0, 0, 0] => "volume",
1088        [0, 1, -1, 0, 0, 0, 0] => "speed",
1089        [0, 1, -2, 0, 0, 0, 0] => "acceleration",
1090        [0, 0, -1, 0, 0, 0, 0] => "frequency",
1091        [1, 1, -2, 0, 0, 0, 0] => "force",
1092        [1, -1, -2, 0, 0, 0, 0] => "pressure",
1093        [1, -1, -1, 0, 0, 0, 0] => "dynamic_viscosity",
1094        [0, 2, -1, 0, 0, 0, 0] => "kinematic_viscosity",
1095        [1, 2, -2, 0, 0, 0, 0] => "energy",
1096        [1, 2, -3, 0, 0, 0, 0] => "power",
1097        [0, 0, 1, 1, 0, 0, 0] => "electric_charge",
1098        [1, 2, -3, -1, 0, 0, 0] => "voltage",
1099        [1, 2, -3, -2, 0, 0, 0] => "resistance",
1100        [-1, -2, 4, 2, 0, 0, 0] => "capacitance",
1101        [-1, -2, 3, 2, 0, 0, 0] => "conductance",
1102        [1, 2, -2, -1, 0, 0, 0] => "magnetic_flux",
1103        [1, 0, -2, -1, 0, 0, 0] => "magnetic_flux_density",
1104        [1, 2, -2, -2, 0, 0, 0] => "inductance",
1105        _ => return format!("dimension[{dim}]"),
1106    };
1107    name.to_string()
1108}
1109
1110/// The ambiguous symbols to list: all of them, or with a group the ones that have a
1111/// candidate in it.
1112fn select_ambiguous(
1113    units: &[(String, &Unit)],
1114    filtered: bool,
1115) -> Vec<&'static dimvexa::AmbiguousDef> {
1116    dimvexa::AMBIGUOUS
1117        .iter()
1118        .filter(|a| {
1119            !filtered
1120                || a.candidates
1121                    .iter()
1122                    .any(|c| units.iter().any(|(_, u)| u.id() == *c))
1123        })
1124        .collect()
1125}
1126
1127/// The config unit that defines `symbol`, if the unit comes from a `--config` file.
1128fn user_unit<'a>(user: &'a [ConfigUnit], symbol: &str) -> Option<&'a ConfigUnit> {
1129    user.iter().find(|u| u.symbol == symbol)
1130}
1131
1132/// JSON for where a unit was defined: `{"file":..,"line":..}` or `null`.
1133fn origin_json(u: Option<&ConfigUnit>) -> String {
1134    u.map_or_else(
1135        || "null".to_string(),
1136        |u| {
1137            format!(
1138                "{{\"file\":{},\"line\":{}}}",
1139                json_string(&u.origin),
1140                u.line
1141            )
1142        },
1143    )
1144}
1145
1146/// A text-table field: tabs and line breaks would break the columns.
1147fn cell(s: &str) -> String {
1148    let t: String = s
1149        .chars()
1150        .map(|c| if c.is_control() { ' ' } else { c })
1151        .collect();
1152    if t.is_empty() { "-".to_string() } else { t }
1153}
1154
1155/// Every group of the whole table with its number of units, in table order. These are the
1156/// values `dimvexa units GROUP` accepts, whatever the current listing is narrowed to.
1157fn group_counts(reg: &Registry) -> Vec<(String, usize)> {
1158    let mut groups: Vec<(String, usize)> = Vec::new();
1159    for u in reg.units() {
1160        let g = group_name(u.dim());
1161        match groups.iter_mut().find(|(name, _)| *name == g) {
1162            Some((_, n)) => *n = n.saturating_add(1),
1163            None => groups.push((g, 1)),
1164        }
1165    }
1166    groups
1167}
1168
1169/// The group overview: one `group<TAB>units` row per group.
1170fn groups_text(groups: &[(String, usize)]) -> String {
1171    let mut s = String::from("# columns: group, units\n");
1172    for (g, n) in groups {
1173        let _ = writeln!(s, "{g}\t{n}");
1174    }
1175    s
1176}
1177
1178fn units_text(
1179    units: &[(String, &Unit)],
1180    ambiguous: &[&dimvexa::AmbiguousDef],
1181    mappings: &[Mapping],
1182    densities: &[Constant],
1183    user: &[ConfigUnit],
1184) -> String {
1185    let mut s = String::from(
1186        "# columns: id, symbol, name, quantity_kind, exactness, prefixes, aliases, source\n",
1187    );
1188    let mut current: Option<&str> = None;
1189    for (g, u) in units {
1190        if current != Some(g.as_str()) {
1191            let _ = writeln!(s, "\n## {g}");
1192            current = Some(g.as_str());
1193        }
1194        let _ = writeln!(
1195            s,
1196            "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
1197            cell(u.id()),
1198            cell(u.symbol()),
1199            cell(u.name()),
1200            u.kind().name(),
1201            u.exactness().name(),
1202            u.prefixes().name(),
1203            cell(&u.aliases().join(", ")),
1204            cell(&user_unit(user, u.id()).map_or_else(
1205                || u.source().to_string(),
1206                |c| format!("{} [yours: {}:{}]", u.source(), c.origin, c.line)
1207            ))
1208        );
1209    }
1210    if !ambiguous.is_empty() {
1211        s.push_str(
1212            "\n## ambiguous symbols (refused unless your config resolves them)\n\
1213# columns: symbol, candidates, planned, note\n",
1214        );
1215        for a in ambiguous {
1216            let note = mappings.iter().find(|m| m.name == a.symbol).map_or_else(
1217                || a.note.to_string(),
1218                |m| {
1219                    format!(
1220                        "resolved by config as {} ({}); {}",
1221                        m.target, m.origin, a.note
1222                    )
1223                },
1224            );
1225            let _ = writeln!(
1226                s,
1227                "{}\t{}\t{}\t{}",
1228                cell(a.symbol),
1229                cell(&a.candidates.join(", ")),
1230                cell(&a.planned.join(", ")),
1231                cell(&note)
1232            );
1233        }
1234    }
1235    if !mappings.is_empty() {
1236        s.push_str(
1237            "\n## config mappings (names your --config files resolve)\n# columns: name, unit, file:line\n",
1238        );
1239        for m in mappings {
1240            let _ = writeln!(
1241                s,
1242                "{}\t{}\t{}:{}",
1243                cell(&m.name),
1244                cell(&m.target),
1245                cell(&m.origin),
1246                m.line
1247            );
1248        }
1249    }
1250    if !densities.is_empty() {
1251        s.push_str(
1252            "\n## config densities (constants you supply; use with --density NAME)\n\
1253# columns: name, kind, value, unit, conditions, source, file:line\n",
1254        );
1255        for b in densities {
1256            let _ = writeln!(
1257                s,
1258                "{}\t{}\t{}\t{}\t{}\t{}\t{}:{}",
1259                cell(&b.name),
1260                b.kind.name(),
1261                b.value.to_fraction_string(),
1262                b.kind.unit(),
1263                cell(&b.conditions),
1264                cell(&b.source),
1265                cell(&b.origin),
1266                b.line
1267            );
1268        }
1269    }
1270    s
1271}
1272
1273fn units_json(
1274    groups: &[(String, usize)],
1275    units: &[(String, &Unit)],
1276    ambiguous: &[&dimvexa::AmbiguousDef],
1277    group: Option<&str>,
1278    loaded: &Loaded,
1279) -> String {
1280    let items: Vec<String> = units
1281        .iter()
1282        .map(|(g, u)| {
1283            let aliases: Vec<String> = u.aliases().iter().map(|a| json_string(a)).collect();
1284            let dims: Vec<String> = u.dim().exponents().iter().map(ToString::to_string).collect();
1285            format!(
1286                "{{\"id\":{},\"symbol\":{},\"name\":{},\"group\":{},\"aliases\":[{}],\"dimension\":[{}],\"quantity_kind\":{},\"exactness\":{},\"prefixes\":{},\"namespace\":{},\"source\":{},\"origin\":{}}}",
1287                json_string(u.id()),
1288                json_string(u.symbol()),
1289                json_string(u.name()),
1290                json_string(g),
1291                aliases.join(","),
1292                dims.join(","),
1293                json_string(u.kind().name()),
1294                json_string(u.exactness().name()),
1295                json_string(u.prefixes().name()),
1296                json_string(u.namespace()),
1297                json_string(u.source()),
1298                origin_json(user_unit(&loaded.units, u.id())),
1299            )
1300        })
1301        .collect();
1302    let strs = |v: &[&str]| {
1303        v.iter()
1304            .map(|x| json_string(x))
1305            .collect::<Vec<_>>()
1306            .join(",")
1307    };
1308    let ambiguous: Vec<String> = ambiguous
1309        .iter()
1310        .map(|a| {
1311            format!(
1312                "{{\"symbol\":{},\"candidates\":[{}],\"planned\":[{}],\"note\":{},\"resolved_by\":{}}}",
1313                json_string(a.symbol),
1314                strs(a.candidates),
1315                strs(a.planned),
1316                json_string(a.note),
1317                loaded
1318                    .mappings
1319                    .iter()
1320                    .find(|m| m.name == a.symbol)
1321                    .map_or_else(
1322                        || "null".to_string(),
1323                        |m| format!(
1324                            "{{\"unit\":{},\"file\":{},\"line\":{}}}",
1325                            json_string(&m.target),
1326                            json_string(&m.origin),
1327                            m.line
1328                        )
1329                    )
1330            )
1331        })
1332        .collect();
1333    let group_list: Vec<String> = groups
1334        .iter()
1335        .map(|(g, n)| format!("{{\"name\":{},\"units\":{n}}}", json_string(g)))
1336        .collect();
1337    let mappings: Vec<String> = loaded
1338        .mappings
1339        .iter()
1340        .map(|m| {
1341            format!(
1342                "{{\"name\":{},\"unit\":{},\"file\":{},\"line\":{}}}",
1343                json_string(&m.name),
1344                json_string(&m.target),
1345                json_string(&m.origin),
1346                m.line
1347            )
1348        })
1349        .collect();
1350    let group = group.map_or_else(
1351        || "null".to_string(),
1352        |g| json_string(&g.to_ascii_lowercase()),
1353    );
1354    format!(
1355        "{{\"schema\":{},\"ok\":true,\"data_version\":{},\"group\":{},\"groups\":[{}],\"units\":[{}],\"ambiguous\":[{}],\"config\":{},\"mappings\":[{}],\"densities\":[{}]}}",
1356        json_string(&schema_id("units")),
1357        json_string(&dimvexa::data_version()),
1358        group,
1359        group_list.join(","),
1360        items.join(","),
1361        ambiguous.join(","),
1362        configs_json(&loaded.configs),
1363        mappings.join(","),
1364        loaded
1365            .densities
1366            .iter()
1367            .map(density_json)
1368            .collect::<Vec<_>>()
1369            .join(",")
1370    )
1371}
1372
1373/// Everything shown for one unit (a table unit, or an expression such as `km`).
1374struct Detail {
1375    /// What was asked for (a symbol, an alias or a mapped name).
1376    queried: String,
1377    id: String,
1378    symbol: Option<String>,
1379    name: Option<String>,
1380    /// `(symbol, name, exponent, family)` of the prefix of a prefixed unit such as `km`.
1381    prefix: Option<(String, String, i32, &'static str)>,
1382    /// `(id, symbol, name)` of the table unit under the prefix.
1383    base: Option<(String, String, String)>,
1384    group: String,
1385    aliases: Vec<String>,
1386    dim: Dim,
1387    quantity_kind: &'static str,
1388    exactness: &'static str,
1389    /// Only for a table unit.
1390    prefixes: Option<&'static str>,
1391    namespace: Option<String>,
1392    factor: String,
1393    offset: String,
1394    sources: Vec<String>,
1395    note: Option<String>,
1396    /// Reference state of the unit (ADR-013), only for a table unit that has one.
1397    conditions: Option<String>,
1398    /// `(file, line)` of the config that defines the unit, if it is a user unit.
1399    origin: Option<(String, usize)>,
1400    /// `(file, line)` of the config `[map]` entry that makes the queried name mean this unit.
1401    mapping: Option<(String, usize)>,
1402}
1403
1404fn unit_detail(
1405    reg: &Registry,
1406    user: &[ConfigUnit],
1407    mappings: &[Mapping],
1408    query: &str,
1409) -> Result<Detail, Error> {
1410    if let Some(u) = reg.get(query) {
1411        return Ok(Detail {
1412            queried: query.to_string(),
1413            id: u.id().to_string(),
1414            symbol: Some(u.symbol().to_string()),
1415            name: Some(u.name().to_string()),
1416            prefix: None,
1417            base: None,
1418            group: group_name(u.dim()),
1419            aliases: u.aliases().to_vec(),
1420            dim: u.dim(),
1421            quantity_kind: u.kind().name(),
1422            exactness: u.exactness().name(),
1423            prefixes: Some(u.prefixes().name()),
1424            namespace: Some(u.namespace().to_string()),
1425            factor: u.factor().to_fraction_string(),
1426            offset: u.offset().to_fraction_string(),
1427            sources: vec![u.source().to_string()],
1428            note: Some(u.note().to_string()),
1429            conditions: Some(u.conditions().to_string()).filter(|c| !c.is_empty()),
1430            origin: user_unit(user, u.id()).map(|c| (c.origin.clone(), c.line)),
1431            mapping: mappings
1432                .iter()
1433                .find(|m| m.name == query)
1434                .map(|m| (m.origin.clone(), m.line)),
1435        });
1436    }
1437    // Not a table symbol: a prefixed or compound expression such as km or kW.h, or an
1438    // error (unknown, ambiguous) that the library words.
1439    let e = reg.parse(query)?;
1440    // A single prefixed unit (`km`) is named from its two parts; a compound is not named yet.
1441    let split = reg.split_prefixed(query);
1442    Ok(Detail {
1443        queried: query.to_string(),
1444        id: query.to_string(),
1445        symbol: split.map(|(p, u)| format!("{}{}", p.display_symbol(), u.symbol())),
1446        name: split.map(|(p, u)| p.compose_name(u.name())),
1447        prefix: split.map(|(p, _)| {
1448            (
1449                p.display_symbol().to_string(),
1450                p.name.to_string(),
1451                p.exp,
1452                p.family.name(),
1453            )
1454        }),
1455        base: split.map(|(_, u)| {
1456            (
1457                u.id().to_string(),
1458                u.symbol().to_string(),
1459                u.name().to_string(),
1460            )
1461        }),
1462        group: group_name(e.dim()),
1463        aliases: Vec::new(),
1464        dim: e.dim(),
1465        quantity_kind: e.kind().name(),
1466        exactness: e.exactness().name(),
1467        prefixes: None,
1468        namespace: None,
1469        factor: e.factor().to_fraction_string(),
1470        offset: e.offset().to_fraction_string(),
1471        sources: e.sources().to_vec(),
1472        note: None,
1473        conditions: None,
1474        origin: None,
1475        mapping: None,
1476    })
1477}
1478
1479fn detail_text(d: &Detail) -> String {
1480    let none = |s: &str| {
1481        if s.is_empty() {
1482            "-".to_string()
1483        } else {
1484            s.to_string()
1485        }
1486    };
1487    let mut s = format!("{}\n", d.id);
1488    let mut line = |k: &str, v: String| {
1489        let _ = writeln!(s, "  {k:<14}{}", cell(&v));
1490    };
1491    // A user unit without a symbol or name of its own would only repeat the id.
1492    if let Some(n) = d.symbol.as_ref().filter(|n| **n != d.id) {
1493        line("symbol", n.clone());
1494    }
1495    if let Some(n) = d.name.as_ref().filter(|n| **n != d.id) {
1496        line("name", n.clone());
1497    }
1498    if let Some((sym, name, exp, family)) = &d.prefix {
1499        let base = if *family == "binary" { 2 } else { 10 };
1500        line("prefix", format!("{sym} ({name}, {base}^{exp})"));
1501    }
1502    if let Some((id, sym, name)) = &d.base {
1503        line("base unit", format!("{id} ({sym}, {name})"));
1504    }
1505    line("group", d.group.clone());
1506    line("aliases", none(&d.aliases.join(", ")));
1507    line("dimension", d.dim.to_string());
1508    line("quantity kind", d.quantity_kind.to_string());
1509    line("exactness", d.exactness.to_string());
1510    if let Some(p) = d.prefixes {
1511        line("prefixes", p.to_string());
1512    }
1513    if let Some(n) = &d.namespace {
1514        line("namespace", n.clone());
1515    }
1516    line("factor to SI", d.factor.clone());
1517    line("offset to SI", d.offset.clone());
1518    if let Some(c) = &d.conditions {
1519        line("conditions", c.clone());
1520    }
1521    for src in &d.sources {
1522        line("source", src.clone());
1523    }
1524    if let Some(n) = d.note.as_deref().filter(|n| !n.is_empty()) {
1525        line("note", n.to_string());
1526    }
1527    if let Some((file, at)) = &d.mapping {
1528        line(
1529            "your name",
1530            format!(
1531                "'{query}' means this unit in your config ({file}:{at})",
1532                query = d.queried
1533            ),
1534        );
1535    }
1536    if let Some((file, at)) = &d.origin {
1537        line(
1538            "defined in",
1539            format!("{file}:{at} (your config, not the shipped table)"),
1540        );
1541    }
1542    s
1543}
1544
1545fn detail_json(d: &Detail) -> String {
1546    let list = |v: &[String]| {
1547        v.iter()
1548            .map(|x| json_string(x))
1549            .collect::<Vec<_>>()
1550            .join(",")
1551    };
1552    let opt = |v: Option<&str>| v.map_or_else(|| "null".to_string(), json_string);
1553    let dims: Vec<String> = d.dim.exponents().iter().map(ToString::to_string).collect();
1554    format!(
1555        "{{\"schema\":{},\"ok\":true,\"data_version\":{},\"unit\":{{\"id\":{},\"symbol\":{},\"name\":{},\"prefix\":{},\"base\":{},\"group\":{},\"aliases\":[{}],\"dimension\":[{}],\"quantity_kind\":{},\"exactness\":{},\"prefixes\":{},\"namespace\":{},\"factor\":{},\"offset\":{},\"sources\":[{}],\"conditions\":{},\"note\":{},\"origin\":{},\"mapping\":{}}}}}",
1556        json_string(&schema_id("units")),
1557        json_string(&dimvexa::data_version()),
1558        json_string(&d.id),
1559        opt(d.symbol.as_deref()),
1560        opt(d.name.as_deref()),
1561        d.prefix.as_ref().map_or_else(
1562            || "null".to_string(),
1563            |(sym, name, exp, family)| format!(
1564                "{{\"symbol\":{},\"name\":{},\"exp\":{exp},\"family\":{}}}",
1565                json_string(sym),
1566                json_string(name),
1567                json_string(family)
1568            ),
1569        ),
1570        d.base.as_ref().map_or_else(
1571            || "null".to_string(),
1572            |(id, sym, name)| format!(
1573                "{{\"id\":{},\"symbol\":{},\"name\":{}}}",
1574                json_string(id),
1575                json_string(sym),
1576                json_string(name)
1577            ),
1578        ),
1579        json_string(&d.group),
1580        list(&d.aliases),
1581        dims.join(","),
1582        json_string(d.quantity_kind),
1583        json_string(d.exactness),
1584        opt(d.prefixes),
1585        opt(d.namespace.as_deref()),
1586        json_string(&d.factor),
1587        json_string(&d.offset),
1588        list(&d.sources),
1589        opt(d.conditions.as_deref()),
1590        opt(d.note.as_deref()),
1591        d.origin.as_ref().map_or_else(
1592            || "null".to_string(),
1593            |(f, l)| format!("{{\"file\":{},\"line\":{l}}}", json_string(f)),
1594        ),
1595        d.mapping.as_ref().map_or_else(
1596            || "null".to_string(),
1597            |(f, l)| {
1598                format!(
1599                    "{{\"name\":{},\"file\":{},\"line\":{l}}}",
1600                    json_string(&d.queried),
1601                    json_string(f)
1602                )
1603            },
1604        ),
1605    )
1606}
1607
1608/// Runs `dimvexa units`; returns the exit code.
1609fn run_units(
1610    target: &UnitsTarget,
1611    config_files: &[String],
1612    json: bool,
1613    out: &mut dyn Write,
1614    err: &mut dyn Write,
1615) -> u8 {
1616    let loaded = match load(config_files) {
1617        Ok(l) => l,
1618        Err(e) => return report_load_error(&e, json, "units", out, err),
1619    };
1620    let mut fail = |code: &str, message: &str, suggestions: &[String], status: u8| -> u8 {
1621        if json {
1622            let _ = writeln!(
1623                out,
1624                "{}",
1625                json_error("units", code, message, &[], suggestions)
1626            );
1627        } else {
1628            let _ = writeln!(err, "error[{code}]: {message}");
1629        }
1630        status
1631    };
1632    let reg = &loaded.reg;
1633    let groups = group_counts(reg);
1634    let (selected, group) = match target {
1635        UnitsTarget::Groups => {
1636            let text = if json {
1637                units_json(&groups, &[], &[], None, &loaded)
1638            } else {
1639                groups_text(&groups)
1640            };
1641            let _ = write!(out, "{text}");
1642            if json {
1643                let _ = writeln!(out);
1644            }
1645            return 0;
1646        }
1647        UnitsTarget::All => (None, None),
1648        UnitsTarget::Query(q) => {
1649            let q_lower = q.to_ascii_lowercase();
1650            if groups.iter().any(|(g, _)| *g == q_lower) {
1651                (Some(q_lower.clone()), Some(q_lower))
1652            } else {
1653                return match unit_detail(reg, &loaded.units, &loaded.mappings, q) {
1654                    Ok(d) => {
1655                        let text = if json {
1656                            detail_json(&d)
1657                        } else {
1658                            detail_text(&d)
1659                        };
1660                        let _ = write!(out, "{text}");
1661                        if json {
1662                            let _ = writeln!(out);
1663                        }
1664                        0
1665                    }
1666                    Err(e) => {
1667                        let hint = if e.code() == "unknown_unit" {
1668                            "; it is not a group either (see 'dimvexa units --groups')".to_string()
1669                        } else {
1670                            String::new()
1671                        };
1672                        let mut near = e.candidates().to_vec();
1673                        for sug in e.suggestions() {
1674                            if !near.contains(sug) {
1675                                near.push(sug.clone());
1676                            }
1677                        }
1678                        fail(e.code(), &format!("{e}{hint}"), &near, 1)
1679                    }
1680                };
1681            }
1682        }
1683    };
1684    let units: Vec<(String, &Unit)> = reg
1685        .units()
1686        .iter()
1687        .map(|u| (group_name(u.dim()), u))
1688        .filter(|(g, _)| selected.as_ref().is_none_or(|want| g == want))
1689        .collect();
1690    let ambiguous = select_ambiguous(&units, group.is_some());
1691    if json {
1692        let _ = writeln!(
1693            out,
1694            "{}",
1695            units_json(&groups, &units, &ambiguous, group.as_deref(), &loaded)
1696        );
1697    } else {
1698        let _ = write!(
1699            out,
1700            "{}",
1701            units_text(
1702                &units,
1703                &ambiguous,
1704                &loaded.mappings,
1705                &loaded.densities,
1706                &loaded.units,
1707            )
1708        );
1709    }
1710    0
1711}
1712
1713/// The columns of `dimvexa configs`: one row per shipped config.
1714fn configs_list_text() -> String {
1715    let mut s = String::from("# columns: name, names, units, densities, hash\n");
1716    for b in dimvexa_config::BUNDLED {
1717        match b.config() {
1718            Ok(c) => {
1719                let _ = writeln!(
1720                    s,
1721                    "{}\t{}\t{}\t{}\t{}",
1722                    b.name,
1723                    c.mappings().len(),
1724                    c.units().len(),
1725                    c.constants().len(),
1726                    c.hash()
1727                );
1728            }
1729            Err(e) => {
1730                let _ = writeln!(s, "{}\terror: {e}", b.name);
1731            }
1732        }
1733    }
1734    s
1735}
1736
1737fn configs_list_json() -> String {
1738    let items: Vec<String> = dimvexa_config::BUNDLED
1739        .iter()
1740        .filter_map(|b| {
1741            let c = b.config().ok()?;
1742            Some(format!(
1743                "{{\"name\":{},\"hash\":{},\"mappings\":{},\"units\":{},\"densities\":{}}}",
1744                json_string(b.name),
1745                json_string(c.hash()),
1746                c.mappings().len(),
1747                c.units().len(),
1748                c.constants().len()
1749            ))
1750        })
1751        .collect();
1752    format!(
1753        "{{\"schema\":{},\"ok\":true,\"configs\":[{}]}}",
1754        json_string(&schema_id("configs")),
1755        items.join(",")
1756    )
1757}
1758
1759/// What one config contains, as text: its names, units and densities with their sources.
1760fn config_detail_text(c: &Config) -> String {
1761    let mut s = format!("{} (hash {})\n", cell(c.origin()), c.hash());
1762    if !c.mappings().is_empty() {
1763        s.push_str(
1764            "\n## names (what they mean once the config is loaded)\n# columns: name, unit\n",
1765        );
1766        for m in c.mappings() {
1767            let _ = writeln!(s, "{}\t{}", cell(&m.name), cell(&m.target));
1768        }
1769    }
1770    if !c.units().is_empty() {
1771        s.push_str("\n## units\n# columns: symbol, base, factor, exactness, source\n");
1772        for u in c.units() {
1773            let _ = writeln!(
1774                s,
1775                "{}\t{}\t{}\t{}\t{}",
1776                cell(&u.symbol),
1777                cell(&u.base),
1778                u.factor.to_fraction_string(),
1779                u.exactness.name(),
1780                cell(&u.source)
1781            );
1782        }
1783    }
1784    if !c.constants().is_empty() {
1785        s.push_str(
1786            "\n## densities (use with --density NAME)\n# columns: name, value, unit, conditions, source\n",
1787        );
1788        for d in c.constants() {
1789            let _ = writeln!(
1790                s,
1791                "{}\t{}\t{}\t{}\t{}",
1792                cell(&d.name),
1793                d.value.to_fraction_string(),
1794                d.kind.unit(),
1795                cell(&d.conditions),
1796                cell(&d.source)
1797            );
1798        }
1799    }
1800    s
1801}
1802
1803fn config_detail_json(c: &Config) -> String {
1804    let mappings: Vec<String> = c
1805        .mappings()
1806        .iter()
1807        .map(|m| {
1808            format!(
1809                "{{\"name\":{},\"unit\":{},\"line\":{}}}",
1810                json_string(&m.name),
1811                json_string(&m.target),
1812                m.line
1813            )
1814        })
1815        .collect();
1816    let units: Vec<String> = c
1817        .units()
1818        .iter()
1819        .map(|u| {
1820            format!(
1821                "{{\"symbol\":{},\"base\":{},\"factor\":{},\"exactness\":{},\"source\":{},\"line\":{}}}",
1822                json_string(&u.symbol),
1823                json_string(&u.base),
1824                json_string(&u.factor.to_fraction_string()),
1825                json_string(u.exactness.name()),
1826                json_string(&u.source),
1827                u.line
1828            )
1829        })
1830        .collect();
1831    let densities: Vec<String> = c.constants().iter().map(density_json).collect();
1832    format!(
1833        "{{\"schema\":{},\"ok\":true,\"config\":{{\"name\":{},\"hash\":{},\"mappings\":[{}],\"units\":[{}],\"densities\":[{}]}}}}",
1834        json_string(&schema_id("configs")),
1835        json_string(c.origin()),
1836        json_string(c.hash()),
1837        mappings.join(","),
1838        units.join(","),
1839        densities.join(",")
1840    )
1841}
1842
1843/// Runs `dimvexa configs [NAME_OR_PATH]`; returns the exit code.
1844fn run_configs(target: Option<&str>, json: bool, out: &mut dyn Write, err: &mut dyn Write) -> u8 {
1845    let text = match target {
1846        None if json => configs_list_json(),
1847        None => configs_list_text(),
1848        Some(spec) => match read_spec(spec) {
1849            Ok(c) if json => config_detail_json(&c),
1850            Ok(c) => config_detail_text(&c),
1851            Err(e) => {
1852                let suggestions: Vec<String> = if matches!(e, LoadError::Io(_)) {
1853                    dimvexa_config::BUNDLED
1854                        .iter()
1855                        .map(|b| b.name.to_string())
1856                        .collect()
1857                } else {
1858                    Vec::new()
1859                };
1860                let (code, message, status) = match &e {
1861                    LoadError::Io(m) => ("usage", m.clone(), 2),
1862                    LoadError::Lib(e) => (e.code(), e.to_string(), 1),
1863                };
1864                if json {
1865                    let _ = writeln!(
1866                        out,
1867                        "{}",
1868                        json_error("configs", code, &message, &[], &suggestions)
1869                    );
1870                } else {
1871                    let _ = writeln!(err, "error[{code}]: {message}");
1872                }
1873                return status;
1874            }
1875        },
1876    };
1877    let _ = write!(out, "{text}");
1878    if json {
1879        let _ = writeln!(out);
1880    }
1881    0
1882}
1883
1884/// Runs the command line `args` (without the program name), writing to `out` and `err`.
1885/// Returns the process exit code.
1886pub fn run<I>(args: I, out: &mut dyn Write, err: &mut dyn Write) -> u8
1887where
1888    I: IntoIterator<Item = String>,
1889{
1890    run_with(args, out, err, Color::default())
1891}
1892
1893/// Like [`run`], with ANSI colour on the streams `color` names. JSON output is never coloured.
1894pub fn run_with<I>(args: I, out: &mut dyn Write, err: &mut dyn Write, color: Color) -> u8
1895where
1896    I: IntoIterator<Item = String>,
1897{
1898    let mut args: Vec<String> = args.into_iter().collect();
1899    let mut color = color;
1900    match take_color_option(&mut args) {
1901        Ok(Some(when)) => {
1902            color = match when {
1903                ColorWhen::Always => Color {
1904                    out: true,
1905                    err: true,
1906                },
1907                ColorWhen::Never => Color {
1908                    out: false,
1909                    err: false,
1910                },
1911                ColorWhen::Auto => color,
1912            };
1913        }
1914        Ok(None) => {}
1915        Err(message) => {
1916            if args.iter().any(|a| a == "--json" || a == "--pretty") {
1917                let line = json_error(command_schema(&args), "usage", &message, &[], &[]);
1918                let _ = writeln!(out, "{line}");
1919            } else {
1920                let _ = writeln!(err, "error[usage]: {message}\n\n{USAGE}");
1921            }
1922            return 2;
1923        }
1924    }
1925    if args.iter().any(|a| a == "--pretty") {
1926        // `--pretty` is `--json` with indentation.
1927        args.retain(|a| a != "--pretty");
1928        if !args.iter().any(|a| a == "--json") {
1929            args.push("--json".to_string());
1930        }
1931        let mut buffered = Vec::new();
1932        let code = run_with(args, &mut buffered, err, color);
1933        let text = String::from_utf8_lossy(&buffered);
1934        for line in text.lines() {
1935            let _ = writeln!(out, "{}", pretty_json(line));
1936        }
1937        return code;
1938    }
1939    if !color.err {
1940        return run_inner(args, out, err, color);
1941    }
1942    let mut buffered = Vec::new();
1943    let code = run_inner(args, out, &mut buffered, color);
1944    let text = String::from_utf8_lossy(&buffered);
1945    let _ = write!(err, "{}", colorize_diagnostics(&text));
1946    code
1947}
1948
1949/// Prints a usage error (JSON on stdout for `--json`, text with the usage on stderr); returns 2.
1950fn report_usage_error(u: &UsageError, out: &mut dyn Write, err: &mut dyn Write) -> u8 {
1951    if u.json {
1952        let _ = writeln!(
1953            out,
1954            "{}",
1955            json_error(u.schema, "usage", &u.message, &[], &u.suggestions)
1956        );
1957    } else {
1958        let _ = writeln!(err, "error[usage]: {}\n\n{USAGE}", u.message);
1959    }
1960    2
1961}
1962
1963fn run_inner<I>(args: I, out: &mut dyn Write, err: &mut dyn Write, color: Color) -> u8
1964where
1965    I: IntoIterator<Item = String>,
1966{
1967    let args: Vec<String> = args.into_iter().collect();
1968    let (parsed, config_files) = match parse_args(&args) {
1969        Ok(p) => p,
1970        Err(u) => return report_usage_error(&u, out, err),
1971    };
1972    let args = match parsed {
1973        Parsed::Help => {
1974            let _ = write!(out, "{}", help_text());
1975            return 0;
1976        }
1977        Parsed::Version => {
1978            let _ = writeln!(
1979                out,
1980                "{} {} (data {})",
1981                dimvexa::PROJECT_NAME,
1982                env!("CARGO_PKG_VERSION"),
1983                dimvexa::data_version()
1984            );
1985            let _ = write!(out, "{}", about());
1986            return 0;
1987        }
1988        Parsed::Units { target, json } => {
1989            return run_units(&target, &config_files, json, out, err);
1990        }
1991        Parsed::Configs { target, json } => {
1992            return run_configs(target.as_deref(), json, out, err);
1993        }
1994        Parsed::Update { check, yes } => return update::run(check, yes, out, err),
1995        Parsed::Convert(a) => a,
1996    };
1997    let loaded = match load(&config_files) {
1998        Ok(l) => l,
1999        Err(e) => return report_load_error(&e, args.json, "convert", out, err),
2000    };
2001    let density = match &args.density {
2002        None => None,
2003        Some(spec) => match resolve_density(spec, &loaded) {
2004            Ok(d) => Some(d),
2005            Err(e) => {
2006                let (code, message, candidates, suggestions, status) = match &e {
2007                    DensityError::Usage(m, known) => {
2008                        ("usage", m.clone(), Vec::new(), known.clone(), 2)
2009                    }
2010                    DensityError::Lib(e) => (
2011                        e.code(),
2012                        format!("--density: {e}"),
2013                        e.candidates().to_vec(),
2014                        e.suggestions().to_vec(),
2015                        1,
2016                    ),
2017                };
2018                if args.json {
2019                    let line = json_error("convert", code, &message, &candidates, &suggestions);
2020                    let _ = writeln!(out, "{line}");
2021                } else {
2022                    let _ = writeln!(err, "error[{code}]: {message}");
2023                }
2024                return status;
2025            }
2026        },
2027    };
2028    match compute(&args, &loaded, density.as_ref()) {
2029        Ok(outcome) => {
2030            let text = if args.json {
2031                match render_json(&outcome) {
2032                    Ok(t) => t,
2033                    Err(e) => return fail(&args, &e, out, err),
2034                }
2035            } else {
2036                render_text(&outcome, color.out)
2037            };
2038            let _ = writeln!(out, "{text}");
2039            if !args.json {
2040                match (outcome.density, &args.density) {
2041                    (Some(b), _) => {
2042                        let _ = writeln!(err, "{}", density_note(b));
2043                    }
2044                    (None, Some(spec)) => {
2045                        let _ = writeln!(
2046                            err,
2047                            "note: density {spec} was not needed: both units have the same dimension"
2048                        );
2049                    }
2050                    (None, None) => {}
2051                }
2052            }
2053            0
2054        }
2055        Err(e) => fail(&args, &e, out, err),
2056    }
2057}
2058
2059fn fail(args: &Args, e: &Error, out: &mut dyn Write, err: &mut dyn Write) -> u8 {
2060    if args.json {
2061        let _ = writeln!(
2062            out,
2063            "{}",
2064            json_error(
2065                "convert",
2066                e.code(),
2067                &e.to_string(),
2068                e.candidates(),
2069                e.suggestions()
2070            )
2071        );
2072    } else {
2073        let _ = writeln!(err, "error[{}]: {e}", e.code());
2074        if let Error::DimensionMismatch { from, to } = e {
2075            let bare = |w: &str| matches!(w, "F" | "C");
2076            for (word, other, other_word) in
2077                [(&args.to, *from, &args.from), (&args.from, *to, &args.to)]
2078            {
2079                // A bare F or C beside a temperature, or beside another bare F or C
2080                // (`0 C F`), is almost always a temperature scale.
2081                if bare(word) && (other == Dim::K || bare(other_word)) {
2082                    let (what, fix) = if word == "F" {
2083                        ("farad", "degF or °F")
2084                    } else {
2085                        ("coulomb", "degC or °C")
2086                    };
2087                    let _ = writeln!(
2088                        err,
2089                        "hint: '{word}' alone is the {what}, not a temperature scale; for the scale write {fix}"
2090                    );
2091                }
2092            }
2093            if Constant::density_needed(*from, *to) && args.density.is_none() {
2094                let _ = writeln!(
2095                    err,
2096                    "hint: mass and volume need a density. Pass your own number, for example \
2097--density 998.2067kg/m3, or a named one: --config water --density water_25C \
2098(docs/adr/014-one-config-mechanism.md)"
2099                );
2100            }
2101        }
2102    }
2103    1
2104}
2105
2106#[cfg(test)]
2107mod tests {
2108    use super::*;
2109
2110    fn run_str(args: &[&str]) -> (u8, String, String) {
2111        let (mut out, mut err) = (Vec::new(), Vec::new());
2112        let code = run(args.iter().map(ToString::to_string), &mut out, &mut err);
2113        (
2114            code,
2115            String::from_utf8(out).unwrap(),
2116            String::from_utf8(err).unwrap(),
2117        )
2118    }
2119
2120    #[test]
2121    fn escapes_json() {
2122        assert_eq!(json_string("a\"b\\c\n\u{1}é"), "\"a\\\"b\\\\c\\n\\u0001é\"");
2123    }
2124
2125    #[test]
2126    fn f64_text_forms() {
2127        assert_eq!(f64_text(36.0), "36");
2128        assert_eq!(f64_text(0.0), "0");
2129        assert_eq!(f64_text(1e300), "1e300");
2130        assert_eq!(f64_text(2.5e-7), "2.5e-7");
2131        assert_eq!(f64_text(-0.5), "-0.5");
2132    }
2133
2134    #[test]
2135    fn basic_conversion_and_negative_numbers() {
2136        assert_eq!(
2137            run_str(&["convert", "10", "m/s", "km/h"]),
2138            (0, "36 km/h\n".into(), String::new())
2139        );
2140        assert_eq!(run_str(&["-40", "degF", "degC"]).1, "-40 degC\n");
2141    }
2142
2143    #[test]
2144    fn units_lists_the_table() {
2145        let (code, out, _) = run_str(&["units"]);
2146        assert_eq!(code, 0);
2147        assert!(out.lines().any(|l| l == "## pressure"));
2148        assert!(out.lines().any(|l| l.starts_with("psi\t")));
2149        assert!(out.lines().any(|l| l.starts_with("ton\t")));
2150        let (code, out, _) = run_str(&["units", "--groups"]);
2151        assert_eq!(code, 0);
2152        assert!(out.lines().any(|l| l == "volume\t15"));
2153        let (code, out, _) = run_str(&["units", "psi"]);
2154        assert_eq!(code, 0);
2155        assert!(out.contains("group         pressure"));
2156        let (code, out, _) = run_str(&["units", "volume"]);
2157        assert_eq!(code, 0);
2158        assert!(out.contains("gal_us\t") && !out.contains("psi\t"));
2159        assert!(out.contains("\ngal\t"), "ambiguous gal belongs to volume");
2160        let (code, out, _) = run_str(&["units", "--json"]);
2161        assert_eq!(code, 0);
2162        assert!(out.contains("\"schema\":\"dimvexa.units/4\""));
2163        assert_eq!(run_str(&["units", "--exact"]).0, 2);
2164        assert_eq!(run_str(&["units", "nope"]).0, 1);
2165        assert_eq!(run_str(&["units", "--groups", "psi"]).0, 2);
2166        assert_eq!(run_str(&["1", "m", "km", "--groups"]).0, 2);
2167        assert_eq!(run_str(&["units", "length", "mass"]).0, 2);
2168        assert_eq!(run_str(&["convert", "units"]).0, 2);
2169    }
2170
2171    #[test]
2172    fn forgives_degree_spacing_and_to() {
2173        for args in [
2174            ["0", "°C", "°F"].as_slice(),
2175            &["0", "°C", "°", "F"],
2176            &["0", "°C", "to", "°F"],
2177            &["convert", "0", "°C", "to", "°", "F"],
2178        ] {
2179            assert_eq!(
2180                run_str(args),
2181                (0, "32 °F\n".into(), String::new()),
2182                "{args:?}"
2183            );
2184        }
2185    }
2186
2187    #[test]
2188    fn bare_f_after_a_temperature_hints_at_degf() {
2189        let (code, _, err) = run_str(&["0", "°C", "F"]);
2190        assert_eq!(code, 1);
2191        assert!(err.contains("'F' alone is the farad"), "{err}");
2192        assert!(err.contains("write degF or °F"), "{err}");
2193    }
2194
2195    #[test]
2196    fn normalisation_edges() {
2197        // the masculine ordinal sign joins; an angle degree before a real unit does not.
2198        assert_eq!(run_str(&["0", "°C", "º", "F"]).1, "32 °F\n");
2199        assert_eq!(run_str(&["180", "°", "rad"]).0, 0);
2200        assert_eq!(run_str(&["0", "°C", "to"]).0, 1);
2201        assert_eq!(run_str(&["units", "°", "F"]).0, 2);
2202    }
2203
2204    #[test]
2205    fn scale_hints_cover_both_sides() {
2206        let hint = |a: &[&str]| run_str(a).2;
2207        assert!(hint(&["32", "F", "°C"]).contains("'F' alone is the farad"));
2208        assert!(hint(&["0", "°C", "C"]).contains("'C' alone is the coulomb"));
2209        assert!(hint(&["0", "C", "F"]).contains("write degC or °C"));
2210        assert!(hint(&["0", "C", "F"]).contains("write degF or °F"));
2211        assert!(!hint(&["1", "m", "F"]).contains("hint:"));
2212    }
2213
2214    #[test]
2215    fn diagnostics_colour_labels_only() {
2216        let text = "error[usage]: x\nhint: y\nnote: z\nerror[ broken\nplain\n";
2217        let c = colorize_diagnostics(text);
2218        assert!(c.contains("\x1b[1;31merror[usage]\x1b[0m: x"));
2219        assert!(c.contains("\x1b[36mhint:\x1b[0m y"));
2220        assert!(c.contains("\x1b[33mnote:\x1b[0m z"));
2221        assert!(c.contains("error[ broken\nplain\n"));
2222    }
2223
2224    #[test]
2225    fn pretty_passes_help_and_version_through() {
2226        assert_eq!(run_str(&["--help", "--pretty"]).1, run_str(&["--help"]).1);
2227        assert_eq!(
2228            run_str(&["--version", "--pretty"]).1,
2229            run_str(&["--version"]).1
2230        );
2231        let both = run_str(&["1", "m", "km", "--pretty", "--json"]).1;
2232        assert_eq!(both, run_str(&["1", "m", "km", "--pretty"]).1);
2233        let (_, units, _) = run_str(&["units", "gal", "--pretty"]);
2234        assert!(units.starts_with("{\n"), "{units}");
2235    }
2236
2237    #[test]
2238    fn color_option_overrides_the_default() {
2239        let run = |a: &[&str], c: Color| {
2240            let (mut out, mut err) = (Vec::new(), Vec::new());
2241            let code = run_with(a.iter().map(ToString::to_string), &mut out, &mut err, c);
2242            (
2243                code,
2244                String::from_utf8(out).unwrap(),
2245                String::from_utf8(err).unwrap(),
2246            )
2247        };
2248        let on = Color {
2249            out: true,
2250            err: true,
2251        };
2252        let off = Color::default();
2253        assert!(
2254            run(&["1", "m", "km", "--color=always"], off)
2255                .1
2256                .contains('\x1b')
2257        );
2258        assert!(
2259            run(&["--color", "always", "1", "m", "km"], off)
2260                .1
2261                .contains('\x1b')
2262        );
2263        assert!(
2264            !run(&["1", "m", "km", "--color=never"], on)
2265                .1
2266                .contains('\x1b')
2267        );
2268        assert!(
2269            run(&["1", "m", "km", "--color=auto"], on)
2270                .1
2271                .contains('\x1b')
2272        );
2273        assert!(
2274            !run(&["1", "m", "km", "--color=always", "--json"], off)
2275                .1
2276                .contains('\x1b')
2277        );
2278        let (code, _, err) = run(&["1", "m", "km", "--color=sometimes"], off);
2279        assert_eq!(code, 2);
2280        assert!(err.contains("auto, always or never"));
2281        assert_eq!(run(&["1", "m", "km", "--color"], off).0, 2);
2282    }
2283
2284    #[test]
2285    fn color_only_where_asked() {
2286        let (mut out, mut err) = (Vec::new(), Vec::new());
2287        let on = Color {
2288            out: true,
2289            err: true,
2290        };
2291        let args = ["0", "°C", "F"].map(String::from);
2292        run_with(args, &mut out, &mut err, on);
2293        assert!(
2294            String::from_utf8(err)
2295                .unwrap()
2296                .contains("\x1b[1;31merror[dimension_mismatch]\x1b[0m")
2297        );
2298        let args = ["0", "degC", "degF"].map(String::from);
2299        let mut out = Vec::new();
2300        run_with(args, &mut out, &mut Vec::new(), on);
2301        assert_eq!(
2302            String::from_utf8(out).unwrap(),
2303            "\x1b[1;32m32\x1b[0m \x1b[36mdegF\x1b[0m\n"
2304        );
2305        let (_, json, _) = run_str(&["0", "degC", "degF", "--json"]);
2306        assert!(!json.contains('\x1b'));
2307    }
2308
2309    #[test]
2310    fn pretty_is_json_with_indentation() {
2311        let (code, out, _) = run_str(&["100", "degC", "degF", "--pretty"]);
2312        assert_eq!(code, 0);
2313        assert!(
2314            out.contains("\n  \"output\": {\n    \"value\": \"212\""),
2315            "{out}"
2316        );
2317        assert!(
2318            out.contains("\"dimension\": [0, 0, 0, 0, 1, 0, 0]"),
2319            "{out}"
2320        );
2321        let compact = run_str(&["100", "degC", "degF", "--json"]).1;
2322        assert_eq!(pretty_json(compact.trim()), out.trim_end());
2323        let (code, out, _) = run_str(&["1", "m", "--pretty"]);
2324        assert_eq!(code, 2);
2325        assert!(out.contains("\"code\": \"usage\""));
2326    }
2327
2328    #[test]
2329    fn usage_errors_exit_2() {
2330        assert_eq!(run_str(&[]).0, 2);
2331        assert_eq!(run_str(&["convert", "1", "m"]).0, 2);
2332        assert_eq!(run_str(&["1", "m", "km", "--nope"]).0, 2);
2333        let (code, out, _) = run_str(&["1", "m", "--json"]);
2334        assert_eq!(code, 2);
2335        assert!(out.contains("\"code\":\"usage\""));
2336    }
2337}