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