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