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