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
29const USAGE: &str = "\
30Usage:
31  dimvexa convert VALUE FROM TO [--exact] [--json] [--config NAME_OR_PATH]... [--density NAME_OR_VALUE]
32  dimvexa VALUE FROM TO [--exact] [--json] [--config NAME_OR_PATH]... [--density NAME_OR_VALUE]
33  dimvexa units [--groups | GROUP | UNIT] [--json] [--config NAME_OR_PATH]...
34  dimvexa configs [NAME_OR_PATH] [--json]
35
36Converts VALUE from unit FROM to unit TO.
37
38Commands:
39  units            list the supported units grouped by quantity, and the ambiguous symbols
40                   that are refused
41  units --groups   list only the groups (length, mass, volume, ...) with their sizes
42  units GROUP      list one group, for example: dimvexa units volume
43  units UNIT       show the details of one unit, for example: dimvexa units psi
44  configs          list the configs that ship with dimvexa (us, gb, ...)
45  configs NAME     show what one config contains: names, units and densities with their
46                   conditions and sources; a path shows your own file
47
48Options:
49  --exact      print the exact rational result (num/den) instead of a decimal
50  --json       machine-readable output (schema dimvexa.convert/2; dimvexa.units/2 for units,
51               dimvexa.configs/1 for configs)
52  --config NAME_OR_PATH  (beta) apply a config (repeatable, later ones win; see
53               docs/adr/014-one-config-mechanism.md). A shipped one is named (us, gb,
54               metric-trade, year-julian, year-civil, water); a file has a path (./mine.toml,
55               or anything with a / or ending in .toml). Without --config the names stay strict
56  --density NAME_OR_VALUE  use a density for mass <-> volume: a name from a loaded config, for
57               example --config water --density water_25C, or your own number, for example
58               --density 998.2067kg/m3. The assumption is printed with the result
59  --version    print version and data table version
60  -h, --help   print this help
61
62Examples:
63  dimvexa convert 10 m/s km/h
64  dimvexa convert 1 psi Pa --exact
65  dimvexa convert 100 degC degF --json
66  dimvexa convert 1 ton kg --config us
67  dimvexa convert 1 mt kg --config metric-trade
68  dimvexa convert 1 L kg --config water --density water_25C
69  dimvexa convert 1 L kg --density 998.2067kg/m3
70  dimvexa convert 1 bbl_oil L --config oil.toml
71
72Precision: the CLI always uses the exact path, so the number printed is the exact result
73rounded once to the nearest f64 (0 ulp). The library's fast f64 path is within 1 ulp (linear
74units) or 2 ulp (temperature scales such as degC and degF) of it; see docs/precision.md.
75
76Exit codes: 0 success, 1 conversion refused or invalid input, 2 usage error.
77";
78
79/// Parsed command line.
80struct Args {
81    /// The density named or written with `--density`, if any.
82    density: Option<String>,
83    value: String,
84    from: String,
85    to: String,
86    exact: bool,
87    json: bool,
88}
89
90/// What `dimvexa units` was asked for.
91enum UnitsTarget {
92    /// The whole table, grouped.
93    All,
94    /// Only the names of the groups.
95    Groups,
96    /// One group (`volume`) or one unit (`psi`, `km`).
97    Query(String),
98}
99
100enum Parsed {
101    Convert(Args),
102    Units {
103        target: UnitsTarget,
104        json: bool,
105    },
106    /// `dimvexa configs [NAME_OR_PATH]`.
107    Configs {
108        target: Option<String>,
109        json: bool,
110    },
111    Help,
112    Version,
113}
114
115/// Why the command line could not be used.
116struct UsageError {
117    message: String,
118    json: bool,
119    /// JSON schema id of the failed command (`convert`, `units` or `configs`).
120    schema: &'static str,
121    suggestions: Vec<String>,
122}
123
124/// The JSON schema name of the command the line asks for: the first word that is not an option.
125fn command_schema(args: &[String]) -> &'static str {
126    match args
127        .iter()
128        .find(|a| !a.starts_with('-'))
129        .map(String::as_str)
130    {
131        Some("units") => "units",
132        Some("configs") => "configs",
133        _ => "convert",
134    }
135}
136
137/// The options that take a value, split off the command line before the rest is read.
138struct ValueOptions {
139    configs: Vec<String>,
140    density: Option<String>,
141}
142
143/// Splits `--config NAME_OR_PATH` (repeatable) and `--density NAME_OR_VALUE`, also as
144/// `--opt=value`.
145fn split_options(args: &[String]) -> Result<(ValueOptions, Vec<String>), UsageError> {
146    let mut opts = ValueOptions {
147        configs: Vec::new(),
148        density: None,
149    };
150    let mut rest = Vec::new();
151    let missing = |what: &str| UsageError {
152        message: format!("'{what}' needs a value"),
153        json: args.iter().any(|x| x == "--json"),
154        schema: command_schema(args),
155        suggestions: Vec::new(),
156    };
157    let mut it = args.iter();
158    while let Some(a) = it.next() {
159        let (name, inline) = match a.split_once('=') {
160            Some((n @ ("--config" | "--density"), v)) => (n, Some(v.to_string())),
161            _ => (a.as_str(), None),
162        };
163        match name {
164            "--bridge" => {
165                return Err(UsageError {
166                    message:
167                        "'--bridge' was renamed '--density' (ADR-014): use --density NAME_OR_VALUE"
168                            .to_string(),
169                    ..missing(name)
170                });
171            }
172            "--config" | "--density" => {
173                let value = match inline {
174                    Some(v) => v,
175                    // The next word must be a value, not another option (`--config --json`).
176                    None => it
177                        .next()
178                        .filter(|v| !v.starts_with("--"))
179                        .cloned()
180                        .ok_or_else(|| missing(name))?,
181                };
182                if name == "--config" {
183                    opts.configs.push(value);
184                } else if opts.density.replace(value).is_some() {
185                    return Err(UsageError {
186                        message: "'--density' is given twice; give one density".to_string(),
187                        ..missing(name)
188                    });
189                }
190            }
191            _ => rest.push(a.clone()),
192        }
193    }
194    Ok((opts, rest))
195}
196
197/// The rest of `dimvexa units ...` after the command word.
198fn parse_units(
199    rest: &[&str],
200    want_groups: bool,
201    json: bool,
202    configs: Vec<String>,
203    usage: &dyn Fn(String) -> UsageError,
204) -> Result<(Parsed, Vec<String>), UsageError> {
205    match (rest, want_groups) {
206        ([], false) => Ok((
207            Parsed::Units {
208                target: UnitsTarget::All,
209                json,
210            },
211            configs,
212        )),
213        ([], true) => Ok((
214            Parsed::Units {
215                target: UnitsTarget::Groups,
216                json,
217            },
218            configs,
219        )),
220        ([_], true) => Err(usage(
221            "'--groups' lists the groups and takes no GROUP or UNIT".to_string(),
222        )),
223        ([query], false) => Ok((
224            Parsed::Units {
225                target: UnitsTarget::Query((*query).to_string()),
226                json,
227            },
228            configs,
229        )),
230        _ => Err(usage(format!(
231            "'units' takes at most one argument (a group such as 'length' or a unit such as 'psi'), got {}",
232            rest.len()
233        ))),
234    }
235}
236
237/// The rest of `dimvexa configs ...` after the command word.
238fn parse_configs(
239    rest: &[&str],
240    json: bool,
241    configs: Vec<String>,
242    usage: &dyn Fn(String) -> UsageError,
243) -> Result<(Parsed, Vec<String>), UsageError> {
244    match rest {
245        [] => Ok((Parsed::Configs { target: None, json }, configs)),
246        [one] => Ok((
247            Parsed::Configs {
248                target: Some((*one).to_string()),
249                json,
250            },
251            configs,
252        )),
253        _ => Err(usage(format!(
254            "'configs' takes at most one argument (a name such as 'us' or a path), got {}",
255            rest.len()
256        ))),
257    }
258}
259
260fn parse_args(all: &[String]) -> Result<(Parsed, Vec<String>), UsageError> {
261    let (opts, args) = split_options(all)?;
262    let configs = opts.configs;
263    let args = args.as_slice();
264    let json = args.iter().any(|a| a == "--json");
265    let schema = command_schema(args);
266    let usage = |message: String| UsageError {
267        message,
268        json,
269        schema,
270        suggestions: Vec::new(),
271    };
272    let mut positional: Vec<&str> = Vec::new();
273    let (mut exact, mut want_json, mut want_groups) = (false, false, false);
274    for a in args {
275        match a.as_str() {
276            "--exact" => exact = true,
277            "--json" => want_json = true,
278            "--groups" => want_groups = true,
279            "-h" | "--help" => return Ok((Parsed::Help, configs)),
280            "-V" | "--version" => return Ok((Parsed::Version, configs)),
281            s if s.starts_with("--") => return Err(usage(format!("unknown option '{s}'"))),
282            s => positional.push(s),
283        }
284    }
285    if let Some((&"units", rest)) = positional.split_first() {
286        if exact {
287            return Err(usage("'units' does not take --exact".to_string()));
288        }
289        if opts.density.is_some() {
290            return Err(usage(
291                "'--density' selects a density for a conversion; 'units' lists them".to_string(),
292            ));
293        }
294        return parse_units(rest, want_groups, want_json, configs, &usage);
295    }
296    if let Some((&"configs", rest)) = positional.split_first() {
297        if exact || want_groups {
298            return Err(usage(
299                "'configs' takes only NAME_OR_PATH and --json".to_string(),
300            ));
301        }
302        if !configs.is_empty() || opts.density.is_some() {
303            return Err(usage(
304                "'configs' shows configs by name or path; it does not take --config or --density"
305                    .to_string(),
306            ));
307        }
308        return parse_configs(rest, want_json, configs, &usage);
309    }
310    if want_groups {
311        return Err(usage("'--groups' only applies to 'units'".to_string()));
312    }
313    let density = opts.density;
314    if positional.first() == Some(&"convert") {
315        positional.remove(0);
316    }
317    match positional.as_slice() {
318        [value, from, to] => Ok((
319            Parsed::Convert(Args {
320                density,
321                value: (*value).to_string(),
322                from: (*from).to_string(),
323                to: (*to).to_string(),
324                exact,
325                json: want_json,
326            }),
327            configs,
328        )),
329        other => Err(usage(format!(
330            "expected VALUE FROM TO, got {} argument(s); try 'dimvexa --help'",
331            other.len()
332        ))),
333    }
334}
335
336/// Escapes a string for JSON.
337fn json_string(s: &str) -> String {
338    let mut out = String::with_capacity(s.len().saturating_add(2));
339    out.push('"');
340    for c in s.chars() {
341        match c {
342            '"' => out.push_str("\\\""),
343            '\\' => out.push_str("\\\\"),
344            '\n' => out.push_str("\\n"),
345            '\r' => out.push_str("\\r"),
346            '\t' => out.push_str("\\t"),
347            c if u32::from(c) < 0x20 => {
348                let _ = write!(out, "\\u{:04x}", u32::from(c));
349            }
350            c => out.push(c),
351        }
352    }
353    out.push('"');
354    out
355}
356
357/// Formats an `f64` as the shortest text that reads back, switching to exponent form for
358/// very large and very small magnitudes. The output is valid JSON as well.
359fn f64_text(x: f64) -> String {
360    let a = x.abs();
361    if a != 0.0 && !(1e-4..1e16).contains(&a) {
362        format!("{x:e}")
363    } else {
364        format!("{x}")
365    }
366}
367
368/// The JSON schema id of a command: `dimvexa.convert/2`, `dimvexa.units/2`, `dimvexa.configs/1`.
369fn schema_id(command: &str) -> String {
370    let version = match command {
371        "configs" => 1,
372        _ => 2,
373    };
374    format!("{}.{command}/{version}", dimvexa::PROJECT_NAME)
375}
376
377fn json_error(
378    schema: &str,
379    code: &str,
380    message: &str,
381    candidates: &[String],
382    suggestions: &[String],
383) -> String {
384    let mut s = format!(
385        "{{\"schema\":{},\"ok\":false,\"error\":{{\"code\":{},\"message\":{}",
386        json_string(&schema_id(schema)),
387        json_string(code),
388        json_string(message)
389    );
390    if !candidates.is_empty() {
391        let list: Vec<String> = candidates.iter().map(|c| json_string(c)).collect();
392        let _ = write!(s, ",\"candidates\":[{}]", list.join(","));
393    }
394    if !suggestions.is_empty() {
395        let list: Vec<String> = suggestions.iter().map(|c| json_string(c)).collect();
396        let _ = write!(s, ",\"suggestions\":[{}]", list.join(","));
397    }
398    s.push_str("}}");
399    s
400}
401
402/// A registry with the user's configs applied, and which configs those were.
403struct Loaded {
404    reg: Registry,
405    /// The name mappings in effect (empty without a config).
406    mappings: Vec<Mapping>,
407    /// The user units in effect (empty without a config).
408    units: Vec<ConfigUnit>,
409    /// The densities in effect (empty without a config).
410    densities: Vec<Constant>,
411    /// `(origin, content hash)` of each config, in the order given. The origin is the file
412    /// path, or `bundled:NAME` for a config that ships with dimvexa.
413    configs: Vec<(String, String)>,
414}
415
416/// Why the registry could not be built.
417enum LoadError {
418    /// A config file could not be read (a usage problem).
419    Io(String),
420    /// The table or a config was refused by the library.
421    Lib(Error),
422}
423
424/// The largest config file we read: a config is a handful of lines, so anything bigger is
425/// a mistake (or a device such as `/dev/zero`), not a config.
426const MAX_CONFIG_BYTES: u64 = 1 << 20;
427
428/// Reads a config file: a regular file of at most [`MAX_CONFIG_BYTES`], valid UTF-8.
429fn read_config(file: &str) -> Result<String, LoadError> {
430    use std::io::Read as _;
431    let fail = |why: String| LoadError::Io(format!("cannot read config '{file}': {why}"));
432    let meta = std::fs::metadata(file).map_err(|e| fail(e.to_string()))?;
433    if !meta.is_file() {
434        return Err(fail("not a regular file".to_string()));
435    }
436    let mut text = String::new();
437    std::fs::File::open(file)
438        .map_err(|e| fail(e.to_string()))?
439        .take(MAX_CONFIG_BYTES.saturating_add(1))
440        .read_to_string(&mut text)
441        .map_err(|e| fail(e.to_string()))?;
442    if u64::try_from(text.len()).map_or(true, |n| n > MAX_CONFIG_BYTES) {
443        return Err(fail(format!("larger than {MAX_CONFIG_BYTES} bytes")));
444    }
445    Ok(text)
446}
447
448/// Reads one `--config` value: a shipped config by name, or a file by path (ADR-014). A bare
449/// word is always a shipped name; a file called `us` is written `./us`.
450fn read_spec(spec: &str) -> Result<Config, LoadError> {
451    if dimvexa_config::is_path(spec) {
452        let text = read_config(spec)?;
453        return Config::parse(&text, spec).map_err(LoadError::Lib);
454    }
455    dimvexa_config::bundled(spec).map_or_else(
456        || {
457            Err(LoadError::Io(format!(
458                "no config named '{spec}'; shipped configs: {} (a file needs a path, for example ./{spec} or {spec}.toml)",
459                bundled_names()
460            )))
461        },
462        |b| b.config().map_err(LoadError::Lib),
463    )
464}
465
466fn bundled_names() -> String {
467    dimvexa_config::BUNDLED
468        .iter()
469        .map(|b| b.name)
470        .collect::<Vec<_>>()
471        .join(", ")
472}
473
474fn load(config_specs: &[String]) -> Result<Loaded, LoadError> {
475    let mut reg = registry().map_err(LoadError::Lib)?;
476    let (mut parsed, mut configs) = (Vec::new(), Vec::new());
477    for spec in config_specs {
478        let cfg = read_spec(spec)?;
479        configs.push((cfg.origin().to_string(), cfg.hash().to_string()));
480        parsed.push(cfg);
481    }
482    let applied = dimvexa_config::apply(&mut reg, &parsed).map_err(LoadError::Lib)?;
483    let densities = dimvexa_config::constants(&parsed);
484    Ok(Loaded {
485        reg,
486        mappings: applied.mappings,
487        units: applied.units,
488        densities,
489        configs,
490    })
491}
492
493/// Prints a load failure for `schema` and returns the exit code.
494fn report_load_error(
495    e: &LoadError,
496    json: bool,
497    schema: &str,
498    out: &mut dyn Write,
499    err: &mut dyn Write,
500) -> u8 {
501    let (code, message, status) = match e {
502        LoadError::Io(m) => ("usage", m.clone(), 2),
503        LoadError::Lib(e) => (e.code(), e.to_string(), 1),
504    };
505    if json {
506        let _ = writeln!(out, "{}", json_error(schema, code, &message, &[], &[]));
507    } else {
508        let _ = writeln!(err, "error[{code}]: {message}");
509    }
510    status
511}
512
513/// JSON array of the configs used: `[{"file":..,"hash":..}]`.
514fn configs_json(configs: &[(String, String)]) -> String {
515    let items: Vec<String> = configs
516        .iter()
517        .map(|(f, h)| {
518            format!(
519                "{{\"file\":{},\"hash\":{}}}",
520                json_string(f),
521                json_string(h)
522            )
523        })
524        .collect();
525    format!("[{}]", items.join(","))
526}
527
528/// Everything computed for a successful conversion, before formatting.
529struct Outcome<'a> {
530    args: &'a Args,
531    configs: &'a [(String, String)],
532    /// The density the conversion went through, if it needed one.
533    density: Option<&'a Constant>,
534    from: UnitExpr,
535    to: UnitExpr,
536    conv: Conversion,
537    exact: Ratio,
538    /// The exact result rounded once to the nearest `f64` (the CLI always uses the exact path).
539    approx: f64,
540}
541
542fn compute<'a>(
543    args: &'a Args,
544    loaded: &'a Loaded,
545    density: Option<&'a Constant>,
546) -> Result<Outcome<'a>, Error> {
547    let reg = &loaded.reg;
548    let value: Ratio = args.value.parse()?;
549    let from = reg.parse(&args.from)?;
550    let to = reg.parse(&args.to)?;
551    // A density is used only when asked for and only when the plain conversion is refused
552    // for lack of a density; otherwise the ordinary exact conversion stands.
553    let (conv, used) = match (Conversion::between(&from, &to), density) {
554        (Ok(conv), _) => (conv, None),
555        (Err(Error::DimensionMismatch { .. }), Some(d)) => (d.conversion(&from, &to)?, Some(d)),
556        (Err(e), _) => return Err(e),
557    };
558    let exact = conv.apply_exact(value)?;
559    let approx = exact.to_f64()?;
560    Ok(Outcome {
561        args,
562        configs: &loaded.configs,
563        density: used,
564        from,
565        to,
566        conv,
567        exact,
568        approx,
569    })
570}
571
572/// Why `--density` could not be resolved.
573enum DensityError {
574    /// A name that no loaded config defines, or a missing value: a usage problem, with the
575    /// known names.
576    Usage(String, Vec<String>),
577    /// The library refused the caller's own number (not a density, ambiguous unit, overflow).
578    Lib(Error),
579}
580
581/// Resolves `--density`: a number with a unit is the caller's own density, anything else is
582/// the name of a density in a loaded config.
583fn resolve_density(spec: &str, loaded: &Loaded) -> Result<Constant, DensityError> {
584    let starts_like_a_number = spec.starts_with(|c: char| c.is_ascii_digit() || "+-.".contains(c));
585    if starts_like_a_number {
586        return Constant::density_from_text(&loaded.reg, spec).map_err(DensityError::Lib);
587    }
588    let known: Vec<String> = loaded.densities.iter().map(|d| d.name.clone()).collect();
589    loaded
590        .densities
591        .iter()
592        .find(|d| d.name == spec)
593        .cloned()
594        .ok_or_else(|| {
595            let message = if known.is_empty() {
596                format!(
597                    "no density named '{spec}': no loaded config has a [[density]] (try --config water, or write your own: --density 998.2067kg/m3)"
598                )
599            } else {
600                format!("no density named '{spec}'; loaded densities: {}", known.join(", "))
601            };
602            DensityError::Usage(message, known)
603        })
604}
605
606fn render_text(o: &Outcome<'_>) -> String {
607    if o.args.exact {
608        format!("{} {}", o.exact.to_fraction_string(), o.to)
609    } else {
610        format!("{} {}", f64_text(o.approx), o.to)
611    }
612}
613
614/// The JSON object describing a density: everything a reader needs to judge the assumption.
615fn density_json(b: &Constant) -> String {
616    format!(
617        "{{\"name\":{},\"kind\":{},\"value\":{},\"unit\":{},\"conditions\":{},\"source\":{},\"file\":{},\"line\":{}}}",
618        json_string(&b.name),
619        json_string(b.kind.name()),
620        json_string(&b.value.to_fraction_string()),
621        json_string(b.kind.unit()),
622        json_string(&b.conditions),
623        json_string(&b.source),
624        json_string(&b.origin),
625        b.line
626    )
627}
628
629/// One line for people: the assumption behind a result that used a density. Text from the config has its
630/// control characters replaced, and a density that is not a short decimal is shown as the
631/// exact fraction, so the note never reads as a different number.
632fn density_note(b: &Constant) -> String {
633    let value = match b.value.to_decimal(15) {
634        Ok((decimal, true)) => decimal,
635        Ok((decimal, false)) => format!("{} (about {decimal})", b.value.to_fraction_string()),
636        Err(_) => b.value.to_fraction_string(),
637    };
638    let clean = |s: &str| -> String {
639        s.chars()
640            .map(|c| if c.is_control() { ' ' } else { c })
641            .collect()
642    };
643    let at = if b.line == 0 {
644        clean(&b.origin)
645    } else {
646        format!("{}:{}", clean(&b.origin), b.line)
647    };
648    format!(
649        "note: used density {} ({} {}; {}; source: {}; {})",
650        clean(&b.name),
651        value,
652        b.kind.unit(),
653        clean(&b.conditions),
654        clean(&b.source),
655        at
656    )
657}
658
659fn render_json(o: &Outcome<'_>) -> Result<String, Error> {
660    let (decimal, complete) = o.exact.to_decimal(15)?;
661    let dims: Vec<String> =
662        o.to.dim()
663            .exponents()
664            .iter()
665            .map(ToString::to_string)
666            .collect();
667    let mut sources: Vec<String> = o.from.sources().to_vec();
668    for s in o.to.sources() {
669        if !sources.contains(s) {
670            sources.push(s.clone());
671        }
672    }
673    if let Some(b) = o.density {
674        sources.push(format!("{} (density {})", b.source, b.name));
675    }
676    let sources: Vec<String> = sources.iter().map(|s| json_string(s)).collect();
677    let exactness = o.density.map_or_else(
678        || o.from.exactness().combine(o.to.exactness()),
679        |b| {
680            o.from
681                .exactness()
682                .combine(o.to.exactness())
683                .combine(b.exactness())
684        },
685    );
686    let packs: Vec<String> = dimvexa::PACKS.iter().map(|p| json_string(p)).collect();
687    Ok(format!(
688        "{{\"schema\":{},\"ok\":true,\
689\"input\":{{\"value\":{},\"unit\":{}}},\
690\"output\":{{\"value\":{},\"unit\":{},\"f64\":{},\"truncated\":{}}},\
691\"exact\":{{\"value\":{},\"factor\":{},\"offset\":{}}},\
692\"exactness\":{},\"dimension\":[{}],\"kind\":{},\"quantity_kind\":{},\
693\"packs\":[{}],\"data_version\":{},\"sources\":[{}],\"config\":{},\"density\":{}}}",
694        json_string(&schema_id("convert")),
695        json_string(&o.args.value),
696        json_string(&o.from.to_string()),
697        json_string(&decimal),
698        json_string(&o.to.to_string()),
699        f64_text(o.approx),
700        !complete,
701        json_string(&o.exact.to_fraction_string()),
702        json_string(&o.conv.factor().to_fraction_string()),
703        json_string(&o.conv.offset().to_fraction_string()),
704        json_string(exactness.name()),
705        dims.join(","),
706        json_string(o.conv.shape().name()),
707        json_string(o.to.kind().name()),
708        packs.join(","),
709        json_string(&dimvexa::data_version()),
710        sources.join(","),
711        configs_json(o.configs),
712        o.density.map_or_else(|| "null".to_string(), density_json),
713    ))
714}
715
716/// Quantity group of a dimension (`[kg, m, s, A, K, mol, cd]` exponents), for listing.
717/// Presentation only: it never takes part in a conversion.
718fn group_name(dim: Dim) -> String {
719    let name = match dim.exponents() {
720        [0, 1, 0, 0, 0, 0, 0] => "length",
721        [1, 0, 0, 0, 0, 0, 0] => "mass",
722        [0, 0, 1, 0, 0, 0, 0] => "time",
723        [0, 0, 0, 1, 0, 0, 0] => "electric_current",
724        [0, 0, 0, 0, 1, 0, 0] => "temperature",
725        [0, 0, 0, 0, 0, 1, 0] => "amount_of_substance",
726        [0, 0, 0, 0, 0, 0, 1] => "luminous_intensity",
727        [0, 0, 0, 0, 0, 0, 0] => "angle",
728        [0, -2, 0, 0, 0, 0, 1] => "illuminance",
729        [0, 0, -1, 0, 0, 1, 0] => "catalytic_activity",
730        [0, 2, -2, 0, 0, 0, 0] => "absorbed_dose",
731        [0, 2, 0, 0, 0, 0, 0] => "area",
732        [0, 3, 0, 0, 0, 0, 0] => "volume",
733        [0, 1, -1, 0, 0, 0, 0] => "speed",
734        [0, 1, -2, 0, 0, 0, 0] => "acceleration",
735        [0, 0, -1, 0, 0, 0, 0] => "frequency",
736        [1, 1, -2, 0, 0, 0, 0] => "force",
737        [1, -1, -2, 0, 0, 0, 0] => "pressure",
738        [1, -1, -1, 0, 0, 0, 0] => "dynamic_viscosity",
739        [0, 2, -1, 0, 0, 0, 0] => "kinematic_viscosity",
740        [1, 2, -2, 0, 0, 0, 0] => "energy",
741        [1, 2, -3, 0, 0, 0, 0] => "power",
742        [0, 0, 1, 1, 0, 0, 0] => "electric_charge",
743        [1, 2, -3, -1, 0, 0, 0] => "voltage",
744        [1, 2, -3, -2, 0, 0, 0] => "resistance",
745        [-1, -2, 4, 2, 0, 0, 0] => "capacitance",
746        [-1, -2, 3, 2, 0, 0, 0] => "conductance",
747        [1, 2, -2, -1, 0, 0, 0] => "magnetic_flux",
748        [1, 0, -2, -1, 0, 0, 0] => "magnetic_flux_density",
749        [1, 2, -2, -2, 0, 0, 0] => "inductance",
750        _ => return format!("dimension[{dim}]"),
751    };
752    name.to_string()
753}
754
755/// The ambiguous symbols to list: all of them, or with a group the ones that have a
756/// candidate in it.
757fn select_ambiguous(
758    units: &[(String, &Unit)],
759    filtered: bool,
760) -> Vec<&'static dimvexa::AmbiguousDef> {
761    dimvexa::AMBIGUOUS
762        .iter()
763        .filter(|a| {
764            !filtered
765                || a.candidates
766                    .iter()
767                    .any(|c| units.iter().any(|(_, u)| u.symbol() == *c))
768        })
769        .collect()
770}
771
772/// The config unit that defines `symbol`, if the unit comes from a `--config` file.
773fn user_unit<'a>(user: &'a [ConfigUnit], symbol: &str) -> Option<&'a ConfigUnit> {
774    user.iter().find(|u| u.symbol == symbol)
775}
776
777/// JSON for where a unit was defined: `{"file":..,"line":..}` or `null`.
778fn origin_json(u: Option<&ConfigUnit>) -> String {
779    u.map_or_else(
780        || "null".to_string(),
781        |u| {
782            format!(
783                "{{\"file\":{},\"line\":{}}}",
784                json_string(&u.origin),
785                u.line
786            )
787        },
788    )
789}
790
791/// A text-table field: tabs and line breaks would break the columns.
792fn cell(s: &str) -> String {
793    let t: String = s
794        .chars()
795        .map(|c| if c.is_control() { ' ' } else { c })
796        .collect();
797    if t.is_empty() { "-".to_string() } else { t }
798}
799
800/// Every group of the whole table with its number of units, in table order. These are the
801/// values `dimvexa units GROUP` accepts, whatever the current listing is narrowed to.
802fn group_counts(reg: &Registry) -> Vec<(String, usize)> {
803    let mut groups: Vec<(String, usize)> = Vec::new();
804    for u in reg.units() {
805        let g = group_name(u.dim());
806        match groups.iter_mut().find(|(name, _)| *name == g) {
807            Some((_, n)) => *n = n.saturating_add(1),
808            None => groups.push((g, 1)),
809        }
810    }
811    groups
812}
813
814/// The group overview: one `group<TAB>units` row per group.
815fn groups_text(groups: &[(String, usize)]) -> String {
816    let mut s = String::from("# columns: group, units\n");
817    for (g, n) in groups {
818        let _ = writeln!(s, "{g}\t{n}");
819    }
820    s
821}
822
823fn units_text(
824    units: &[(String, &Unit)],
825    ambiguous: &[&dimvexa::AmbiguousDef],
826    mappings: &[Mapping],
827    densities: &[Constant],
828    user: &[ConfigUnit],
829) -> String {
830    let mut s =
831        String::from("# columns: symbol, quantity_kind, exactness, prefixes, aliases, source\n");
832    let mut current: Option<&str> = None;
833    for (g, u) in units {
834        if current != Some(g.as_str()) {
835            let _ = writeln!(s, "\n## {g}");
836            current = Some(g.as_str());
837        }
838        let _ = writeln!(
839            s,
840            "{}\t{}\t{}\t{}\t{}\t{}",
841            cell(u.symbol()),
842            u.kind().name(),
843            u.exactness().name(),
844            u.prefixes().name(),
845            cell(&u.aliases().join(", ")),
846            cell(&user_unit(user, u.symbol()).map_or_else(
847                || u.source().to_string(),
848                |c| format!("{} [yours: {}:{}]", u.source(), c.origin, c.line)
849            ))
850        );
851    }
852    if !ambiguous.is_empty() {
853        s.push_str(
854            "\n## ambiguous symbols (refused unless your config resolves them)\n\
855# columns: symbol, candidates, planned, note\n",
856        );
857        for a in ambiguous {
858            let note = mappings.iter().find(|m| m.name == a.symbol).map_or_else(
859                || a.note.to_string(),
860                |m| {
861                    format!(
862                        "resolved by config as {} ({}); {}",
863                        m.target, m.origin, a.note
864                    )
865                },
866            );
867            let _ = writeln!(
868                s,
869                "{}\t{}\t{}\t{}",
870                cell(a.symbol),
871                cell(&a.candidates.join(", ")),
872                cell(&a.planned.join(", ")),
873                cell(&note)
874            );
875        }
876    }
877    if !mappings.is_empty() {
878        s.push_str(
879            "\n## config mappings (names your --config files resolve)\n# columns: name, unit, file:line\n",
880        );
881        for m in mappings {
882            let _ = writeln!(
883                s,
884                "{}\t{}\t{}:{}",
885                cell(&m.name),
886                cell(&m.target),
887                cell(&m.origin),
888                m.line
889            );
890        }
891    }
892    if !densities.is_empty() {
893        s.push_str(
894            "\n## config densities (constants you supply; use with --density NAME)\n\
895# columns: name, kind, value, unit, conditions, source, file:line\n",
896        );
897        for b in densities {
898            let _ = writeln!(
899                s,
900                "{}\t{}\t{}\t{}\t{}\t{}\t{}:{}",
901                cell(&b.name),
902                b.kind.name(),
903                b.value.to_fraction_string(),
904                b.kind.unit(),
905                cell(&b.conditions),
906                cell(&b.source),
907                cell(&b.origin),
908                b.line
909            );
910        }
911    }
912    s
913}
914
915fn units_json(
916    groups: &[(String, usize)],
917    units: &[(String, &Unit)],
918    ambiguous: &[&dimvexa::AmbiguousDef],
919    group: Option<&str>,
920    loaded: &Loaded,
921) -> String {
922    let items: Vec<String> = units
923        .iter()
924        .map(|(g, u)| {
925            let aliases: Vec<String> = u.aliases().iter().map(|a| json_string(a)).collect();
926            let dims: Vec<String> = u.dim().exponents().iter().map(ToString::to_string).collect();
927            format!(
928                "{{\"symbol\":{},\"group\":{},\"aliases\":[{}],\"dimension\":[{}],\"quantity_kind\":{},\"exactness\":{},\"prefixes\":{},\"namespace\":{},\"source\":{},\"origin\":{}}}",
929                json_string(u.symbol()),
930                json_string(g),
931                aliases.join(","),
932                dims.join(","),
933                json_string(u.kind().name()),
934                json_string(u.exactness().name()),
935                json_string(u.prefixes().name()),
936                json_string(u.namespace()),
937                json_string(u.source()),
938                origin_json(user_unit(&loaded.units, u.symbol())),
939            )
940        })
941        .collect();
942    let strs = |v: &[&str]| {
943        v.iter()
944            .map(|x| json_string(x))
945            .collect::<Vec<_>>()
946            .join(",")
947    };
948    let ambiguous: Vec<String> = ambiguous
949        .iter()
950        .map(|a| {
951            format!(
952                "{{\"symbol\":{},\"candidates\":[{}],\"planned\":[{}],\"note\":{},\"resolved_by\":{}}}",
953                json_string(a.symbol),
954                strs(a.candidates),
955                strs(a.planned),
956                json_string(a.note),
957                loaded
958                    .mappings
959                    .iter()
960                    .find(|m| m.name == a.symbol)
961                    .map_or_else(
962                        || "null".to_string(),
963                        |m| format!(
964                            "{{\"unit\":{},\"file\":{},\"line\":{}}}",
965                            json_string(&m.target),
966                            json_string(&m.origin),
967                            m.line
968                        )
969                    )
970            )
971        })
972        .collect();
973    let group_list: Vec<String> = groups
974        .iter()
975        .map(|(g, n)| format!("{{\"name\":{},\"units\":{n}}}", json_string(g)))
976        .collect();
977    let mappings: Vec<String> = loaded
978        .mappings
979        .iter()
980        .map(|m| {
981            format!(
982                "{{\"name\":{},\"unit\":{},\"file\":{},\"line\":{}}}",
983                json_string(&m.name),
984                json_string(&m.target),
985                json_string(&m.origin),
986                m.line
987            )
988        })
989        .collect();
990    let group = group.map_or_else(
991        || "null".to_string(),
992        |g| json_string(&g.to_ascii_lowercase()),
993    );
994    format!(
995        "{{\"schema\":{},\"ok\":true,\"data_version\":{},\"group\":{},\"groups\":[{}],\"units\":[{}],\"ambiguous\":[{}],\"config\":{},\"mappings\":[{}],\"densities\":[{}]}}",
996        json_string(&schema_id("units")),
997        json_string(&dimvexa::data_version()),
998        group,
999        group_list.join(","),
1000        items.join(","),
1001        ambiguous.join(","),
1002        configs_json(&loaded.configs),
1003        mappings.join(","),
1004        loaded
1005            .densities
1006            .iter()
1007            .map(density_json)
1008            .collect::<Vec<_>>()
1009            .join(",")
1010    )
1011}
1012
1013/// Everything shown for one unit (a table unit, or an expression such as `km`).
1014struct Detail {
1015    /// What was asked for (a symbol, an alias or a mapped name).
1016    queried: String,
1017    symbol: String,
1018    group: String,
1019    aliases: Vec<String>,
1020    dim: Dim,
1021    quantity_kind: &'static str,
1022    exactness: &'static str,
1023    /// Only for a table unit.
1024    prefixes: Option<&'static str>,
1025    namespace: Option<String>,
1026    factor: String,
1027    offset: String,
1028    sources: Vec<String>,
1029    note: Option<String>,
1030    /// `(file, line)` of the config that defines the unit, if it is a user unit.
1031    origin: Option<(String, usize)>,
1032    /// `(file, line)` of the config `[map]` entry that makes the queried name mean this unit.
1033    mapping: Option<(String, usize)>,
1034}
1035
1036fn unit_detail(
1037    reg: &Registry,
1038    user: &[ConfigUnit],
1039    mappings: &[Mapping],
1040    query: &str,
1041) -> Result<Detail, Error> {
1042    if let Some(u) = reg.get(query) {
1043        return Ok(Detail {
1044            queried: query.to_string(),
1045            symbol: u.symbol().to_string(),
1046            group: group_name(u.dim()),
1047            aliases: u.aliases().to_vec(),
1048            dim: u.dim(),
1049            quantity_kind: u.kind().name(),
1050            exactness: u.exactness().name(),
1051            prefixes: Some(u.prefixes().name()),
1052            namespace: Some(u.namespace().to_string()),
1053            factor: u.factor().to_fraction_string(),
1054            offset: u.offset().to_fraction_string(),
1055            sources: vec![u.source().to_string()],
1056            note: Some(u.note().to_string()),
1057            origin: user_unit(user, u.symbol()).map(|c| (c.origin.clone(), c.line)),
1058            mapping: mappings
1059                .iter()
1060                .find(|m| m.name == query)
1061                .map(|m| (m.origin.clone(), m.line)),
1062        });
1063    }
1064    // Not a table symbol: a prefixed or compound expression such as km or kW.h, or an
1065    // error (unknown, ambiguous) that the library words.
1066    let e = reg.parse(query)?;
1067    Ok(Detail {
1068        queried: query.to_string(),
1069        symbol: query.to_string(),
1070        group: group_name(e.dim()),
1071        aliases: Vec::new(),
1072        dim: e.dim(),
1073        quantity_kind: e.kind().name(),
1074        exactness: e.exactness().name(),
1075        prefixes: None,
1076        namespace: None,
1077        factor: e.factor().to_fraction_string(),
1078        offset: e.offset().to_fraction_string(),
1079        sources: e.sources().to_vec(),
1080        note: None,
1081        origin: None,
1082        mapping: None,
1083    })
1084}
1085
1086fn detail_text(d: &Detail) -> String {
1087    let none = |s: &str| {
1088        if s.is_empty() {
1089            "-".to_string()
1090        } else {
1091            s.to_string()
1092        }
1093    };
1094    let mut s = format!("{}\n", d.symbol);
1095    let mut line = |k: &str, v: String| {
1096        let _ = writeln!(s, "  {k:<14}{}", cell(&v));
1097    };
1098    line("group", d.group.clone());
1099    line("aliases", none(&d.aliases.join(", ")));
1100    line("dimension", d.dim.to_string());
1101    line("quantity kind", d.quantity_kind.to_string());
1102    line("exactness", d.exactness.to_string());
1103    if let Some(p) = d.prefixes {
1104        line("prefixes", p.to_string());
1105    }
1106    if let Some(n) = &d.namespace {
1107        line("namespace", n.clone());
1108    }
1109    line("factor to SI", d.factor.clone());
1110    line("offset to SI", d.offset.clone());
1111    for src in &d.sources {
1112        line("source", src.clone());
1113    }
1114    if let Some(n) = d.note.as_deref().filter(|n| !n.is_empty()) {
1115        line("note", n.to_string());
1116    }
1117    if let Some((file, at)) = &d.mapping {
1118        line(
1119            "your name",
1120            format!(
1121                "'{query}' means this unit in your config ({file}:{at})",
1122                query = d.queried
1123            ),
1124        );
1125    }
1126    if let Some((file, at)) = &d.origin {
1127        line(
1128            "defined in",
1129            format!("{file}:{at} (your config, not the shipped table)"),
1130        );
1131    }
1132    s
1133}
1134
1135fn detail_json(d: &Detail) -> String {
1136    let list = |v: &[String]| {
1137        v.iter()
1138            .map(|x| json_string(x))
1139            .collect::<Vec<_>>()
1140            .join(",")
1141    };
1142    let opt = |v: Option<&str>| v.map_or_else(|| "null".to_string(), json_string);
1143    let dims: Vec<String> = d.dim.exponents().iter().map(ToString::to_string).collect();
1144    format!(
1145        "{{\"schema\":{},\"ok\":true,\"data_version\":{},\"unit\":{{\"symbol\":{},\"group\":{},\"aliases\":[{}],\"dimension\":[{}],\"quantity_kind\":{},\"exactness\":{},\"prefixes\":{},\"namespace\":{},\"factor\":{},\"offset\":{},\"sources\":[{}],\"note\":{},\"origin\":{},\"mapping\":{}}}}}",
1146        json_string(&schema_id("units")),
1147        json_string(&dimvexa::data_version()),
1148        json_string(&d.symbol),
1149        json_string(&d.group),
1150        list(&d.aliases),
1151        dims.join(","),
1152        json_string(d.quantity_kind),
1153        json_string(d.exactness),
1154        opt(d.prefixes),
1155        opt(d.namespace.as_deref()),
1156        json_string(&d.factor),
1157        json_string(&d.offset),
1158        list(&d.sources),
1159        opt(d.note.as_deref()),
1160        d.origin.as_ref().map_or_else(
1161            || "null".to_string(),
1162            |(f, l)| format!("{{\"file\":{},\"line\":{l}}}", json_string(f)),
1163        ),
1164        d.mapping.as_ref().map_or_else(
1165            || "null".to_string(),
1166            |(f, l)| {
1167                format!(
1168                    "{{\"name\":{},\"file\":{},\"line\":{l}}}",
1169                    json_string(&d.queried),
1170                    json_string(f)
1171                )
1172            },
1173        ),
1174    )
1175}
1176
1177/// Runs `dimvexa units`; returns the exit code.
1178fn run_units(
1179    target: &UnitsTarget,
1180    config_files: &[String],
1181    json: bool,
1182    out: &mut dyn Write,
1183    err: &mut dyn Write,
1184) -> u8 {
1185    let loaded = match load(config_files) {
1186        Ok(l) => l,
1187        Err(e) => return report_load_error(&e, json, "units", out, err),
1188    };
1189    let mut fail = |code: &str, message: &str, suggestions: &[String], status: u8| -> u8 {
1190        if json {
1191            let _ = writeln!(
1192                out,
1193                "{}",
1194                json_error("units", code, message, &[], suggestions)
1195            );
1196        } else {
1197            let _ = writeln!(err, "error[{code}]: {message}");
1198        }
1199        status
1200    };
1201    let reg = &loaded.reg;
1202    let groups = group_counts(reg);
1203    let (selected, group) = match target {
1204        UnitsTarget::Groups => {
1205            let text = if json {
1206                units_json(&groups, &[], &[], None, &loaded)
1207            } else {
1208                groups_text(&groups)
1209            };
1210            let _ = write!(out, "{text}");
1211            if json {
1212                let _ = writeln!(out);
1213            }
1214            return 0;
1215        }
1216        UnitsTarget::All => (None, None),
1217        UnitsTarget::Query(q) => {
1218            let q_lower = q.to_ascii_lowercase();
1219            if groups.iter().any(|(g, _)| *g == q_lower) {
1220                (Some(q_lower.clone()), Some(q_lower))
1221            } else {
1222                return match unit_detail(reg, &loaded.units, &loaded.mappings, q) {
1223                    Ok(d) => {
1224                        let text = if json {
1225                            detail_json(&d)
1226                        } else {
1227                            detail_text(&d)
1228                        };
1229                        let _ = write!(out, "{text}");
1230                        if json {
1231                            let _ = writeln!(out);
1232                        }
1233                        0
1234                    }
1235                    Err(e) => {
1236                        let hint = if e.code() == "unknown_unit" {
1237                            "; it is not a group either (see 'dimvexa units --groups')".to_string()
1238                        } else {
1239                            String::new()
1240                        };
1241                        let mut near = e.candidates().to_vec();
1242                        for sug in e.suggestions() {
1243                            if !near.contains(sug) {
1244                                near.push(sug.clone());
1245                            }
1246                        }
1247                        fail(e.code(), &format!("{e}{hint}"), &near, 1)
1248                    }
1249                };
1250            }
1251        }
1252    };
1253    let units: Vec<(String, &Unit)> = reg
1254        .units()
1255        .iter()
1256        .map(|u| (group_name(u.dim()), u))
1257        .filter(|(g, _)| selected.as_ref().is_none_or(|want| g == want))
1258        .collect();
1259    let ambiguous = select_ambiguous(&units, group.is_some());
1260    if json {
1261        let _ = writeln!(
1262            out,
1263            "{}",
1264            units_json(&groups, &units, &ambiguous, group.as_deref(), &loaded)
1265        );
1266    } else {
1267        let _ = write!(
1268            out,
1269            "{}",
1270            units_text(
1271                &units,
1272                &ambiguous,
1273                &loaded.mappings,
1274                &loaded.densities,
1275                &loaded.units,
1276            )
1277        );
1278    }
1279    0
1280}
1281
1282/// The columns of `dimvexa configs`: one row per shipped config.
1283fn configs_list_text() -> String {
1284    let mut s = String::from("# columns: name, names, units, densities, hash\n");
1285    for b in dimvexa_config::BUNDLED {
1286        match b.config() {
1287            Ok(c) => {
1288                let _ = writeln!(
1289                    s,
1290                    "{}\t{}\t{}\t{}\t{}",
1291                    b.name,
1292                    c.mappings().len(),
1293                    c.units().len(),
1294                    c.constants().len(),
1295                    c.hash()
1296                );
1297            }
1298            Err(e) => {
1299                let _ = writeln!(s, "{}\terror: {e}", b.name);
1300            }
1301        }
1302    }
1303    s
1304}
1305
1306fn configs_list_json() -> String {
1307    let items: Vec<String> = dimvexa_config::BUNDLED
1308        .iter()
1309        .filter_map(|b| {
1310            let c = b.config().ok()?;
1311            Some(format!(
1312                "{{\"name\":{},\"hash\":{},\"mappings\":{},\"units\":{},\"densities\":{}}}",
1313                json_string(b.name),
1314                json_string(c.hash()),
1315                c.mappings().len(),
1316                c.units().len(),
1317                c.constants().len()
1318            ))
1319        })
1320        .collect();
1321    format!(
1322        "{{\"schema\":{},\"ok\":true,\"configs\":[{}]}}",
1323        json_string(&schema_id("configs")),
1324        items.join(",")
1325    )
1326}
1327
1328/// What one config contains, as text: its names, units and densities with their sources.
1329fn config_detail_text(c: &Config) -> String {
1330    let mut s = format!("{} (hash {})\n", cell(c.origin()), c.hash());
1331    if !c.mappings().is_empty() {
1332        s.push_str(
1333            "\n## names (what they mean once the config is loaded)\n# columns: name, unit\n",
1334        );
1335        for m in c.mappings() {
1336            let _ = writeln!(s, "{}\t{}", cell(&m.name), cell(&m.target));
1337        }
1338    }
1339    if !c.units().is_empty() {
1340        s.push_str("\n## units\n# columns: symbol, base, factor, exactness, source\n");
1341        for u in c.units() {
1342            let _ = writeln!(
1343                s,
1344                "{}\t{}\t{}\t{}\t{}",
1345                cell(&u.symbol),
1346                cell(&u.base),
1347                u.factor.to_fraction_string(),
1348                u.exactness.name(),
1349                cell(&u.source)
1350            );
1351        }
1352    }
1353    if !c.constants().is_empty() {
1354        s.push_str(
1355            "\n## densities (use with --density NAME)\n# columns: name, value, unit, conditions, source\n",
1356        );
1357        for d in c.constants() {
1358            let _ = writeln!(
1359                s,
1360                "{}\t{}\t{}\t{}\t{}",
1361                cell(&d.name),
1362                d.value.to_fraction_string(),
1363                d.kind.unit(),
1364                cell(&d.conditions),
1365                cell(&d.source)
1366            );
1367        }
1368    }
1369    s
1370}
1371
1372fn config_detail_json(c: &Config) -> String {
1373    let mappings: Vec<String> = c
1374        .mappings()
1375        .iter()
1376        .map(|m| {
1377            format!(
1378                "{{\"name\":{},\"unit\":{},\"line\":{}}}",
1379                json_string(&m.name),
1380                json_string(&m.target),
1381                m.line
1382            )
1383        })
1384        .collect();
1385    let units: Vec<String> = c
1386        .units()
1387        .iter()
1388        .map(|u| {
1389            format!(
1390                "{{\"symbol\":{},\"base\":{},\"factor\":{},\"exactness\":{},\"source\":{},\"line\":{}}}",
1391                json_string(&u.symbol),
1392                json_string(&u.base),
1393                json_string(&u.factor.to_fraction_string()),
1394                json_string(u.exactness.name()),
1395                json_string(&u.source),
1396                u.line
1397            )
1398        })
1399        .collect();
1400    let densities: Vec<String> = c.constants().iter().map(density_json).collect();
1401    format!(
1402        "{{\"schema\":{},\"ok\":true,\"config\":{{\"name\":{},\"hash\":{},\"mappings\":[{}],\"units\":[{}],\"densities\":[{}]}}}}",
1403        json_string(&schema_id("configs")),
1404        json_string(c.origin()),
1405        json_string(c.hash()),
1406        mappings.join(","),
1407        units.join(","),
1408        densities.join(",")
1409    )
1410}
1411
1412/// Runs `dimvexa configs [NAME_OR_PATH]`; returns the exit code.
1413fn run_configs(target: Option<&str>, json: bool, out: &mut dyn Write, err: &mut dyn Write) -> u8 {
1414    let text = match target {
1415        None if json => configs_list_json(),
1416        None => configs_list_text(),
1417        Some(spec) => match read_spec(spec) {
1418            Ok(c) if json => config_detail_json(&c),
1419            Ok(c) => config_detail_text(&c),
1420            Err(e) => {
1421                let suggestions: Vec<String> = if matches!(e, LoadError::Io(_)) {
1422                    dimvexa_config::BUNDLED
1423                        .iter()
1424                        .map(|b| b.name.to_string())
1425                        .collect()
1426                } else {
1427                    Vec::new()
1428                };
1429                let (code, message, status) = match &e {
1430                    LoadError::Io(m) => ("usage", m.clone(), 2),
1431                    LoadError::Lib(e) => (e.code(), e.to_string(), 1),
1432                };
1433                if json {
1434                    let _ = writeln!(
1435                        out,
1436                        "{}",
1437                        json_error("configs", code, &message, &[], &suggestions)
1438                    );
1439                } else {
1440                    let _ = writeln!(err, "error[{code}]: {message}");
1441                }
1442                return status;
1443            }
1444        },
1445    };
1446    let _ = write!(out, "{text}");
1447    if json {
1448        let _ = writeln!(out);
1449    }
1450    0
1451}
1452
1453/// Runs the command line `args` (without the program name), writing to `out` and `err`.
1454/// Returns the process exit code.
1455pub fn run<I>(args: I, out: &mut dyn Write, err: &mut dyn Write) -> u8
1456where
1457    I: IntoIterator<Item = String>,
1458{
1459    let args: Vec<String> = args.into_iter().collect();
1460    let (parsed, config_files) = match parse_args(&args) {
1461        Ok(p) => p,
1462        Err(u) => {
1463            if u.json {
1464                let _ = writeln!(
1465                    out,
1466                    "{}",
1467                    json_error(u.schema, "usage", &u.message, &[], &u.suggestions)
1468                );
1469            } else {
1470                let _ = writeln!(err, "error[usage]: {}\n\n{USAGE}", u.message);
1471            }
1472            return 2;
1473        }
1474    };
1475    let args = match parsed {
1476        Parsed::Help => {
1477            let _ = write!(out, "{USAGE}");
1478            return 0;
1479        }
1480        Parsed::Version => {
1481            let _ = writeln!(
1482                out,
1483                "{} {} (data {})",
1484                dimvexa::PROJECT_NAME,
1485                env!("CARGO_PKG_VERSION"),
1486                dimvexa::data_version()
1487            );
1488            return 0;
1489        }
1490        Parsed::Units { target, json } => {
1491            return run_units(&target, &config_files, json, out, err);
1492        }
1493        Parsed::Configs { target, json } => {
1494            return run_configs(target.as_deref(), json, out, err);
1495        }
1496        Parsed::Convert(a) => a,
1497    };
1498    let loaded = match load(&config_files) {
1499        Ok(l) => l,
1500        Err(e) => return report_load_error(&e, args.json, "convert", out, err),
1501    };
1502    let density = match &args.density {
1503        None => None,
1504        Some(spec) => match resolve_density(spec, &loaded) {
1505            Ok(d) => Some(d),
1506            Err(e) => {
1507                let (code, message, candidates, suggestions, status) = match &e {
1508                    DensityError::Usage(m, known) => {
1509                        ("usage", m.clone(), Vec::new(), known.clone(), 2)
1510                    }
1511                    DensityError::Lib(e) => (
1512                        e.code(),
1513                        format!("--density: {e}"),
1514                        e.candidates().to_vec(),
1515                        e.suggestions().to_vec(),
1516                        1,
1517                    ),
1518                };
1519                if args.json {
1520                    let line = json_error("convert", code, &message, &candidates, &suggestions);
1521                    let _ = writeln!(out, "{line}");
1522                } else {
1523                    let _ = writeln!(err, "error[{code}]: {message}");
1524                }
1525                return status;
1526            }
1527        },
1528    };
1529    match compute(&args, &loaded, density.as_ref()) {
1530        Ok(outcome) => {
1531            let text = if args.json {
1532                match render_json(&outcome) {
1533                    Ok(t) => t,
1534                    Err(e) => return fail(&args, &e, out, err),
1535                }
1536            } else {
1537                render_text(&outcome)
1538            };
1539            let _ = writeln!(out, "{text}");
1540            if !args.json {
1541                match (outcome.density, &args.density) {
1542                    (Some(b), _) => {
1543                        let _ = writeln!(err, "{}", density_note(b));
1544                    }
1545                    (None, Some(spec)) => {
1546                        let _ = writeln!(
1547                            err,
1548                            "note: density {spec} was not needed: both units have the same dimension"
1549                        );
1550                    }
1551                    (None, None) => {}
1552                }
1553            }
1554            0
1555        }
1556        Err(e) => fail(&args, &e, out, err),
1557    }
1558}
1559
1560fn fail(args: &Args, e: &Error, out: &mut dyn Write, err: &mut dyn Write) -> u8 {
1561    if args.json {
1562        let _ = writeln!(
1563            out,
1564            "{}",
1565            json_error(
1566                "convert",
1567                e.code(),
1568                &e.to_string(),
1569                e.candidates(),
1570                e.suggestions()
1571            )
1572        );
1573    } else {
1574        let _ = writeln!(err, "error[{}]: {e}", e.code());
1575        if let Error::DimensionMismatch { from, to } = e {
1576            if Constant::density_needed(*from, *to) && args.density.is_none() {
1577                let _ = writeln!(
1578                    err,
1579                    "hint: mass and volume need a density. Pass your own number, for example \
1580--density 998.2067kg/m3, or a named one: --config water --density water_25C \
1581(docs/adr/014-one-config-mechanism.md)"
1582                );
1583            }
1584        }
1585    }
1586    1
1587}
1588
1589#[cfg(test)]
1590mod tests {
1591    use super::*;
1592
1593    fn run_str(args: &[&str]) -> (u8, String, String) {
1594        let (mut out, mut err) = (Vec::new(), Vec::new());
1595        let code = run(args.iter().map(ToString::to_string), &mut out, &mut err);
1596        (
1597            code,
1598            String::from_utf8(out).unwrap(),
1599            String::from_utf8(err).unwrap(),
1600        )
1601    }
1602
1603    #[test]
1604    fn escapes_json() {
1605        assert_eq!(json_string("a\"b\\c\n\u{1}é"), "\"a\\\"b\\\\c\\n\\u0001é\"");
1606    }
1607
1608    #[test]
1609    fn f64_text_forms() {
1610        assert_eq!(f64_text(36.0), "36");
1611        assert_eq!(f64_text(0.0), "0");
1612        assert_eq!(f64_text(1e300), "1e300");
1613        assert_eq!(f64_text(2.5e-7), "2.5e-7");
1614        assert_eq!(f64_text(-0.5), "-0.5");
1615    }
1616
1617    #[test]
1618    fn basic_conversion_and_negative_numbers() {
1619        assert_eq!(
1620            run_str(&["convert", "10", "m/s", "km/h"]),
1621            (0, "36 km/h\n".into(), String::new())
1622        );
1623        assert_eq!(run_str(&["-40", "degF", "degC"]).1, "-40 degC\n");
1624    }
1625
1626    #[test]
1627    fn units_lists_the_table() {
1628        let (code, out, _) = run_str(&["units"]);
1629        assert_eq!(code, 0);
1630        assert!(out.lines().any(|l| l == "## pressure"));
1631        assert!(out.lines().any(|l| l.starts_with("psi\t")));
1632        assert!(out.lines().any(|l| l.starts_with("ton\t")));
1633        let (code, out, _) = run_str(&["units", "--groups"]);
1634        assert_eq!(code, 0);
1635        assert!(out.lines().any(|l| l == "volume\t12"));
1636        let (code, out, _) = run_str(&["units", "psi"]);
1637        assert_eq!(code, 0);
1638        assert!(out.contains("group         pressure"));
1639        let (code, out, _) = run_str(&["units", "volume"]);
1640        assert_eq!(code, 0);
1641        assert!(out.contains("gal_us\t") && !out.contains("psi\t"));
1642        assert!(out.contains("\ngal\t"), "ambiguous gal belongs to volume");
1643        let (code, out, _) = run_str(&["units", "--json"]);
1644        assert_eq!(code, 0);
1645        assert!(out.contains("\"schema\":\"dimvexa.units/2\""));
1646        assert_eq!(run_str(&["units", "--exact"]).0, 2);
1647        assert_eq!(run_str(&["units", "nope"]).0, 1);
1648        assert_eq!(run_str(&["units", "--groups", "psi"]).0, 2);
1649        assert_eq!(run_str(&["1", "m", "km", "--groups"]).0, 2);
1650        assert_eq!(run_str(&["units", "length", "mass"]).0, 2);
1651        assert_eq!(run_str(&["convert", "units"]).0, 2);
1652    }
1653
1654    #[test]
1655    fn usage_errors_exit_2() {
1656        assert_eq!(run_str(&[]).0, 2);
1657        assert_eq!(run_str(&["convert", "1", "m"]).0, 2);
1658        assert_eq!(run_str(&["1", "m", "km", "--nope"]).0, 2);
1659        let (code, out, _) = run_str(&["1", "m", "--json"]);
1660        assert_eq!(code, 2);
1661        assert!(out.contains("\"code\":\"usage\""));
1662    }
1663}