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