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