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/3 for units,
63 dimvexa.configs/1 for configs)
64 --color WHEN auto (default: a terminal, unless NO_COLOR is set), always (also
65 CLICOLOR_FORCE=1) or never; JSON is never coloured
66 --pretty --json, indented for reading (same fields, same order)
67 --config NAME_OR_PATH (beta) apply a config (repeatable, later ones win; see
68 docs/adr/014-one-config-mechanism.md). A shipped one is named (us, gb,
69 metric-trade, year-julian, year-civil, water); a file has a path (./mine.toml,
70 or anything with a / or ending in .toml). Without --config the names stay strict
71 --density NAME_OR_VALUE use a density for mass <-> volume: a name from a loaded config, for
72 example --config water --density water_25C, or your own number, for example
73 --density 998.2067kg/m3. The assumption is printed with the result
74 --version print version and data table version
75 -h, --help print this help
76
77Examples:
78 dimvexa convert 10 m/s km/h
79 dimvexa convert 1 psi Pa --exact
80 dimvexa convert 100 degC degF --json
81 dimvexa convert 100 degC degF --pretty
82 dimvexa convert 1 ton kg --config us
83 dimvexa convert 1 mt kg --config metric-trade
84 dimvexa convert 1 L kg --config water --density water_25C
85 dimvexa convert 1 L kg --density 998.2067kg/m3
86 dimvexa convert 1 bbl_oil L --config oil.toml
87
88Precision: the CLI always uses the exact path, so the number printed is the exact result
89rounded once to the nearest f64 (0 ulp). The library's fast f64 path is within 1 ulp (linear
90units) or 2 ulp (temperature scales such as degC and degF) of it; see docs/precision.md.
91
92Exit codes: 0 success, 1 conversion refused or invalid input, 2 usage error; `update --check`
93also exits 3 when a newer release exists.
94";
95
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 "units" => 3,
708 _ => 2,
709 };
710 format!("{}.{command}/{version}", dimvexa::PROJECT_NAME)
711}
712
713fn json_error(
714 schema: &str,
715 code: &str,
716 message: &str,
717 candidates: &[String],
718 suggestions: &[String],
719) -> String {
720 let mut s = format!(
721 "{{\"schema\":{},\"ok\":false,\"error\":{{\"code\":{},\"message\":{}",
722 json_string(&schema_id(schema)),
723 json_string(code),
724 json_string(message)
725 );
726 if !candidates.is_empty() {
727 let list: Vec<String> = candidates.iter().map(|c| json_string(c)).collect();
728 let _ = write!(s, ",\"candidates\":[{}]", list.join(","));
729 }
730 if !suggestions.is_empty() {
731 let list: Vec<String> = suggestions.iter().map(|c| json_string(c)).collect();
732 let _ = write!(s, ",\"suggestions\":[{}]", list.join(","));
733 }
734 s.push_str("}}");
735 s
736}
737
738struct Loaded {
740 reg: Registry,
741 mappings: Vec<Mapping>,
743 units: Vec<ConfigUnit>,
745 densities: Vec<Constant>,
747 configs: Vec<(String, String)>,
750}
751
752enum LoadError {
754 Io(String),
756 Lib(Error),
758}
759
760const MAX_CONFIG_BYTES: u64 = 1 << 20;
763
764fn read_config(file: &str) -> Result<String, LoadError> {
766 use std::io::Read as _;
767 let fail = |why: String| LoadError::Io(format!("cannot read config '{file}': {why}"));
768 let meta = std::fs::metadata(file).map_err(|e| fail(e.to_string()))?;
769 if !meta.is_file() {
770 return Err(fail("not a regular file".to_string()));
771 }
772 let mut text = String::new();
773 std::fs::File::open(file)
774 .map_err(|e| fail(e.to_string()))?
775 .take(MAX_CONFIG_BYTES.saturating_add(1))
776 .read_to_string(&mut text)
777 .map_err(|e| fail(e.to_string()))?;
778 if u64::try_from(text.len()).map_or(true, |n| n > MAX_CONFIG_BYTES) {
779 return Err(fail(format!("larger than {MAX_CONFIG_BYTES} bytes")));
780 }
781 Ok(text)
782}
783
784fn read_spec(spec: &str) -> Result<Config, LoadError> {
787 if dimvexa_config::is_path(spec) {
788 let text = read_config(spec)?;
789 return Config::parse(&text, spec).map_err(LoadError::Lib);
790 }
791 dimvexa_config::bundled(spec).map_or_else(
792 || {
793 Err(LoadError::Io(format!(
794 "no config named '{spec}'; shipped configs: {} (a file needs a path, for example ./{spec} or {spec}.toml)",
795 bundled_names()
796 )))
797 },
798 |b| b.config().map_err(LoadError::Lib),
799 )
800}
801
802fn bundled_names() -> String {
803 dimvexa_config::BUNDLED
804 .iter()
805 .map(|b| b.name)
806 .collect::<Vec<_>>()
807 .join(", ")
808}
809
810fn load(config_specs: &[String]) -> Result<Loaded, LoadError> {
811 let mut reg = registry().map_err(LoadError::Lib)?;
812 let (mut parsed, mut configs) = (Vec::new(), Vec::new());
813 for spec in config_specs {
814 let cfg = read_spec(spec)?;
815 configs.push((cfg.origin().to_string(), cfg.hash().to_string()));
816 parsed.push(cfg);
817 }
818 let applied = dimvexa_config::apply(&mut reg, &parsed).map_err(LoadError::Lib)?;
819 let densities = dimvexa_config::constants(&parsed);
820 Ok(Loaded {
821 reg,
822 mappings: applied.mappings,
823 units: applied.units,
824 densities,
825 configs,
826 })
827}
828
829fn report_load_error(
831 e: &LoadError,
832 json: bool,
833 schema: &str,
834 out: &mut dyn Write,
835 err: &mut dyn Write,
836) -> u8 {
837 let (code, message, status) = match e {
838 LoadError::Io(m) => ("usage", m.clone(), 2),
839 LoadError::Lib(e) => (e.code(), e.to_string(), 1),
840 };
841 if json {
842 let _ = writeln!(out, "{}", json_error(schema, code, &message, &[], &[]));
843 } else {
844 let _ = writeln!(err, "error[{code}]: {message}");
845 }
846 status
847}
848
849fn configs_json(configs: &[(String, String)]) -> String {
851 let items: Vec<String> = configs
852 .iter()
853 .map(|(f, h)| {
854 format!(
855 "{{\"file\":{},\"hash\":{}}}",
856 json_string(f),
857 json_string(h)
858 )
859 })
860 .collect();
861 format!("[{}]", items.join(","))
862}
863
864struct Outcome<'a> {
866 args: &'a Args,
867 configs: &'a [(String, String)],
868 density: Option<&'a Constant>,
870 from: UnitExpr,
871 to: UnitExpr,
872 conv: Conversion,
873 exact: Ratio,
874 approx: f64,
876}
877
878fn compute<'a>(
879 args: &'a Args,
880 loaded: &'a Loaded,
881 density: Option<&'a Constant>,
882) -> Result<Outcome<'a>, Error> {
883 let reg = &loaded.reg;
884 let value: Ratio = args.value.parse()?;
885 let from = reg.parse(&args.from)?;
886 let to = reg.parse(&args.to)?;
887 let (conv, used) = match (Conversion::between(&from, &to), density) {
890 (Ok(conv), _) => (conv, None),
891 (Err(Error::DimensionMismatch { .. }), Some(d)) => (d.conversion(&from, &to)?, Some(d)),
892 (Err(e), _) => return Err(e),
893 };
894 let exact = conv.apply_exact(value)?;
895 let approx = exact.to_f64()?;
896 Ok(Outcome {
897 args,
898 configs: &loaded.configs,
899 density: used,
900 from,
901 to,
902 conv,
903 exact,
904 approx,
905 })
906}
907
908enum DensityError {
910 Usage(String, Vec<String>),
913 Lib(Error),
915}
916
917fn resolve_density(spec: &str, loaded: &Loaded) -> Result<Constant, DensityError> {
920 let starts_like_a_number = spec.starts_with(|c: char| c.is_ascii_digit() || "+-.".contains(c));
921 if starts_like_a_number {
922 return Constant::density_from_text(&loaded.reg, spec).map_err(DensityError::Lib);
923 }
924 let known: Vec<String> = loaded.densities.iter().map(|d| d.name.clone()).collect();
925 loaded
926 .densities
927 .iter()
928 .find(|d| d.name == spec)
929 .cloned()
930 .ok_or_else(|| {
931 let message = if known.is_empty() {
932 format!(
933 "no density named '{spec}': no loaded config has a [[density]] (try --config water, or write your own: --density 998.2067kg/m3)"
934 )
935 } else {
936 format!("no density named '{spec}'; loaded densities: {}", known.join(", "))
937 };
938 DensityError::Usage(message, known)
939 })
940}
941
942fn render_text(o: &Outcome<'_>, color: bool) -> String {
943 let value = if o.args.exact {
944 o.exact.to_fraction_string()
945 } else {
946 f64_text(o.approx)
947 };
948 if color {
949 format!("{BOLD_GREEN}{value}{RESET} {CYAN}{}{RESET}", o.to)
950 } else {
951 format!("{value} {}", o.to)
952 }
953}
954
955fn density_json(b: &Constant) -> String {
957 format!(
958 "{{\"name\":{},\"kind\":{},\"value\":{},\"unit\":{},\"conditions\":{},\"source\":{},\"file\":{},\"line\":{}}}",
959 json_string(&b.name),
960 json_string(b.kind.name()),
961 json_string(&b.value.to_fraction_string()),
962 json_string(b.kind.unit()),
963 json_string(&b.conditions),
964 json_string(&b.source),
965 json_string(&b.origin),
966 b.line
967 )
968}
969
970fn density_note(b: &Constant) -> String {
974 let value = match b.value.to_decimal(15) {
975 Ok((decimal, true)) => decimal,
976 Ok((decimal, false)) => format!("{} (about {decimal})", b.value.to_fraction_string()),
977 Err(_) => b.value.to_fraction_string(),
978 };
979 let clean = |s: &str| -> String {
980 s.chars()
981 .map(|c| if c.is_control() { ' ' } else { c })
982 .collect()
983 };
984 let at = if b.line == 0 {
985 clean(&b.origin)
986 } else {
987 format!("{}:{}", clean(&b.origin), b.line)
988 };
989 format!(
990 "note: used density {} ({} {}; {}; source: {}; {})",
991 clean(&b.name),
992 value,
993 b.kind.unit(),
994 clean(&b.conditions),
995 clean(&b.source),
996 at
997 )
998}
999
1000fn render_json(o: &Outcome<'_>) -> Result<String, Error> {
1001 let (decimal, complete) = o.exact.to_decimal(15)?;
1002 let dims: Vec<String> =
1003 o.to.dim()
1004 .exponents()
1005 .iter()
1006 .map(ToString::to_string)
1007 .collect();
1008 let mut sources: Vec<String> = o.from.sources().to_vec();
1009 for s in o.to.sources() {
1010 if !sources.contains(s) {
1011 sources.push(s.clone());
1012 }
1013 }
1014 if let Some(b) = o.density {
1015 sources.push(format!("{} (density {})", b.source, b.name));
1016 }
1017 let sources: Vec<String> = sources.iter().map(|s| json_string(s)).collect();
1018 let exactness = o.density.map_or_else(
1019 || o.from.exactness().combine(o.to.exactness()),
1020 |b| {
1021 o.from
1022 .exactness()
1023 .combine(o.to.exactness())
1024 .combine(b.exactness())
1025 },
1026 );
1027 let packs: Vec<String> = dimvexa::PACKS.iter().map(|p| json_string(p)).collect();
1028 Ok(format!(
1029 "{{\"schema\":{},\"ok\":true,\
1030\"input\":{{\"value\":{},\"unit\":{}}},\
1031\"output\":{{\"value\":{},\"unit\":{},\"f64\":{},\"truncated\":{}}},\
1032\"exact\":{{\"value\":{},\"factor\":{},\"offset\":{}}},\
1033\"exactness\":{},\"dimension\":[{}],\"kind\":{},\"quantity_kind\":{},\
1034\"packs\":[{}],\"data_version\":{},\"sources\":[{}],\"config\":{},\"density\":{}}}",
1035 json_string(&schema_id("convert")),
1036 json_string(&o.args.value),
1037 json_string(&o.from.to_string()),
1038 json_string(&decimal),
1039 json_string(&o.to.to_string()),
1040 f64_text(o.approx),
1041 !complete,
1042 json_string(&o.exact.to_fraction_string()),
1043 json_string(&o.conv.factor().to_fraction_string()),
1044 json_string(&o.conv.offset().to_fraction_string()),
1045 json_string(exactness.name()),
1046 dims.join(","),
1047 json_string(o.conv.shape().name()),
1048 json_string(o.to.kind().name()),
1049 packs.join(","),
1050 json_string(&dimvexa::data_version()),
1051 sources.join(","),
1052 configs_json(o.configs),
1053 o.density.map_or_else(|| "null".to_string(), density_json),
1054 ))
1055}
1056
1057fn group_name(dim: Dim) -> String {
1060 let name = match dim.exponents() {
1061 [0, 1, 0, 0, 0, 0, 0] => "length",
1062 [1, 0, 0, 0, 0, 0, 0] => "mass",
1063 [0, 0, 1, 0, 0, 0, 0] => "time",
1064 [0, 0, 0, 1, 0, 0, 0] => "electric_current",
1065 [0, 0, 0, 0, 1, 0, 0] => "temperature",
1066 [0, 0, 0, 0, 0, 1, 0] => "amount_of_substance",
1067 [0, 0, 0, 0, 0, 0, 1] => "luminous_intensity",
1068 [0, 0, 0, 0, 0, 0, 0] => "angle",
1069 [0, -2, 0, 0, 0, 0, 1] => "illuminance",
1070 [0, 0, -1, 0, 0, 1, 0] => "catalytic_activity",
1071 [0, 2, -2, 0, 0, 0, 0] => "absorbed_dose",
1072 [0, 2, 0, 0, 0, 0, 0] => "area",
1073 [0, 3, 0, 0, 0, 0, 0] => "volume",
1074 [0, 1, -1, 0, 0, 0, 0] => "speed",
1075 [0, 1, -2, 0, 0, 0, 0] => "acceleration",
1076 [0, 0, -1, 0, 0, 0, 0] => "frequency",
1077 [1, 1, -2, 0, 0, 0, 0] => "force",
1078 [1, -1, -2, 0, 0, 0, 0] => "pressure",
1079 [1, -1, -1, 0, 0, 0, 0] => "dynamic_viscosity",
1080 [0, 2, -1, 0, 0, 0, 0] => "kinematic_viscosity",
1081 [1, 2, -2, 0, 0, 0, 0] => "energy",
1082 [1, 2, -3, 0, 0, 0, 0] => "power",
1083 [0, 0, 1, 1, 0, 0, 0] => "electric_charge",
1084 [1, 2, -3, -1, 0, 0, 0] => "voltage",
1085 [1, 2, -3, -2, 0, 0, 0] => "resistance",
1086 [-1, -2, 4, 2, 0, 0, 0] => "capacitance",
1087 [-1, -2, 3, 2, 0, 0, 0] => "conductance",
1088 [1, 2, -2, -1, 0, 0, 0] => "magnetic_flux",
1089 [1, 0, -2, -1, 0, 0, 0] => "magnetic_flux_density",
1090 [1, 2, -2, -2, 0, 0, 0] => "inductance",
1091 _ => return format!("dimension[{dim}]"),
1092 };
1093 name.to_string()
1094}
1095
1096fn select_ambiguous(
1099 units: &[(String, &Unit)],
1100 filtered: bool,
1101) -> Vec<&'static dimvexa::AmbiguousDef> {
1102 dimvexa::AMBIGUOUS
1103 .iter()
1104 .filter(|a| {
1105 !filtered
1106 || a.candidates
1107 .iter()
1108 .any(|c| units.iter().any(|(_, u)| u.id() == *c))
1109 })
1110 .collect()
1111}
1112
1113fn user_unit<'a>(user: &'a [ConfigUnit], symbol: &str) -> Option<&'a ConfigUnit> {
1115 user.iter().find(|u| u.symbol == symbol)
1116}
1117
1118fn origin_json(u: Option<&ConfigUnit>) -> String {
1120 u.map_or_else(
1121 || "null".to_string(),
1122 |u| {
1123 format!(
1124 "{{\"file\":{},\"line\":{}}}",
1125 json_string(&u.origin),
1126 u.line
1127 )
1128 },
1129 )
1130}
1131
1132fn cell(s: &str) -> String {
1134 let t: String = s
1135 .chars()
1136 .map(|c| if c.is_control() { ' ' } else { c })
1137 .collect();
1138 if t.is_empty() { "-".to_string() } else { t }
1139}
1140
1141fn group_counts(reg: &Registry) -> Vec<(String, usize)> {
1144 let mut groups: Vec<(String, usize)> = Vec::new();
1145 for u in reg.units() {
1146 let g = group_name(u.dim());
1147 match groups.iter_mut().find(|(name, _)| *name == g) {
1148 Some((_, n)) => *n = n.saturating_add(1),
1149 None => groups.push((g, 1)),
1150 }
1151 }
1152 groups
1153}
1154
1155fn groups_text(groups: &[(String, usize)]) -> String {
1157 let mut s = String::from("# columns: group, units\n");
1158 for (g, n) in groups {
1159 let _ = writeln!(s, "{g}\t{n}");
1160 }
1161 s
1162}
1163
1164fn units_text(
1165 units: &[(String, &Unit)],
1166 ambiguous: &[&dimvexa::AmbiguousDef],
1167 mappings: &[Mapping],
1168 densities: &[Constant],
1169 user: &[ConfigUnit],
1170) -> String {
1171 let mut s = String::from(
1172 "# columns: id, symbol, name, quantity_kind, exactness, prefixes, aliases, source\n",
1173 );
1174 let mut current: Option<&str> = None;
1175 for (g, u) in units {
1176 if current != Some(g.as_str()) {
1177 let _ = writeln!(s, "\n## {g}");
1178 current = Some(g.as_str());
1179 }
1180 let _ = writeln!(
1181 s,
1182 "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
1183 cell(u.id()),
1184 cell(u.symbol()),
1185 cell(u.name()),
1186 u.kind().name(),
1187 u.exactness().name(),
1188 u.prefixes().name(),
1189 cell(&u.aliases().join(", ")),
1190 cell(&user_unit(user, u.id()).map_or_else(
1191 || u.source().to_string(),
1192 |c| format!("{} [yours: {}:{}]", u.source(), c.origin, c.line)
1193 ))
1194 );
1195 }
1196 if !ambiguous.is_empty() {
1197 s.push_str(
1198 "\n## ambiguous symbols (refused unless your config resolves them)\n\
1199# columns: symbol, candidates, planned, note\n",
1200 );
1201 for a in ambiguous {
1202 let note = mappings.iter().find(|m| m.name == a.symbol).map_or_else(
1203 || a.note.to_string(),
1204 |m| {
1205 format!(
1206 "resolved by config as {} ({}); {}",
1207 m.target, m.origin, a.note
1208 )
1209 },
1210 );
1211 let _ = writeln!(
1212 s,
1213 "{}\t{}\t{}\t{}",
1214 cell(a.symbol),
1215 cell(&a.candidates.join(", ")),
1216 cell(&a.planned.join(", ")),
1217 cell(¬e)
1218 );
1219 }
1220 }
1221 if !mappings.is_empty() {
1222 s.push_str(
1223 "\n## config mappings (names your --config files resolve)\n# columns: name, unit, file:line\n",
1224 );
1225 for m in mappings {
1226 let _ = writeln!(
1227 s,
1228 "{}\t{}\t{}:{}",
1229 cell(&m.name),
1230 cell(&m.target),
1231 cell(&m.origin),
1232 m.line
1233 );
1234 }
1235 }
1236 if !densities.is_empty() {
1237 s.push_str(
1238 "\n## config densities (constants you supply; use with --density NAME)\n\
1239# columns: name, kind, value, unit, conditions, source, file:line\n",
1240 );
1241 for b in densities {
1242 let _ = writeln!(
1243 s,
1244 "{}\t{}\t{}\t{}\t{}\t{}\t{}:{}",
1245 cell(&b.name),
1246 b.kind.name(),
1247 b.value.to_fraction_string(),
1248 b.kind.unit(),
1249 cell(&b.conditions),
1250 cell(&b.source),
1251 cell(&b.origin),
1252 b.line
1253 );
1254 }
1255 }
1256 s
1257}
1258
1259fn units_json(
1260 groups: &[(String, usize)],
1261 units: &[(String, &Unit)],
1262 ambiguous: &[&dimvexa::AmbiguousDef],
1263 group: Option<&str>,
1264 loaded: &Loaded,
1265) -> String {
1266 let items: Vec<String> = units
1267 .iter()
1268 .map(|(g, u)| {
1269 let aliases: Vec<String> = u.aliases().iter().map(|a| json_string(a)).collect();
1270 let dims: Vec<String> = u.dim().exponents().iter().map(ToString::to_string).collect();
1271 format!(
1272 "{{\"id\":{},\"symbol\":{},\"name\":{},\"group\":{},\"aliases\":[{}],\"dimension\":[{}],\"quantity_kind\":{},\"exactness\":{},\"prefixes\":{},\"namespace\":{},\"source\":{},\"origin\":{}}}",
1273 json_string(u.id()),
1274 json_string(u.symbol()),
1275 json_string(u.name()),
1276 json_string(g),
1277 aliases.join(","),
1278 dims.join(","),
1279 json_string(u.kind().name()),
1280 json_string(u.exactness().name()),
1281 json_string(u.prefixes().name()),
1282 json_string(u.namespace()),
1283 json_string(u.source()),
1284 origin_json(user_unit(&loaded.units, u.id())),
1285 )
1286 })
1287 .collect();
1288 let strs = |v: &[&str]| {
1289 v.iter()
1290 .map(|x| json_string(x))
1291 .collect::<Vec<_>>()
1292 .join(",")
1293 };
1294 let ambiguous: Vec<String> = ambiguous
1295 .iter()
1296 .map(|a| {
1297 format!(
1298 "{{\"symbol\":{},\"candidates\":[{}],\"planned\":[{}],\"note\":{},\"resolved_by\":{}}}",
1299 json_string(a.symbol),
1300 strs(a.candidates),
1301 strs(a.planned),
1302 json_string(a.note),
1303 loaded
1304 .mappings
1305 .iter()
1306 .find(|m| m.name == a.symbol)
1307 .map_or_else(
1308 || "null".to_string(),
1309 |m| format!(
1310 "{{\"unit\":{},\"file\":{},\"line\":{}}}",
1311 json_string(&m.target),
1312 json_string(&m.origin),
1313 m.line
1314 )
1315 )
1316 )
1317 })
1318 .collect();
1319 let group_list: Vec<String> = groups
1320 .iter()
1321 .map(|(g, n)| format!("{{\"name\":{},\"units\":{n}}}", json_string(g)))
1322 .collect();
1323 let mappings: Vec<String> = loaded
1324 .mappings
1325 .iter()
1326 .map(|m| {
1327 format!(
1328 "{{\"name\":{},\"unit\":{},\"file\":{},\"line\":{}}}",
1329 json_string(&m.name),
1330 json_string(&m.target),
1331 json_string(&m.origin),
1332 m.line
1333 )
1334 })
1335 .collect();
1336 let group = group.map_or_else(
1337 || "null".to_string(),
1338 |g| json_string(&g.to_ascii_lowercase()),
1339 );
1340 format!(
1341 "{{\"schema\":{},\"ok\":true,\"data_version\":{},\"group\":{},\"groups\":[{}],\"units\":[{}],\"ambiguous\":[{}],\"config\":{},\"mappings\":[{}],\"densities\":[{}]}}",
1342 json_string(&schema_id("units")),
1343 json_string(&dimvexa::data_version()),
1344 group,
1345 group_list.join(","),
1346 items.join(","),
1347 ambiguous.join(","),
1348 configs_json(&loaded.configs),
1349 mappings.join(","),
1350 loaded
1351 .densities
1352 .iter()
1353 .map(density_json)
1354 .collect::<Vec<_>>()
1355 .join(",")
1356 )
1357}
1358
1359struct Detail {
1361 queried: String,
1363 id: String,
1364 symbol: Option<String>,
1365 name: Option<String>,
1366 prefix: Option<(String, String, i32, &'static str)>,
1368 base: Option<(String, String, String)>,
1370 group: String,
1371 aliases: Vec<String>,
1372 dim: Dim,
1373 quantity_kind: &'static str,
1374 exactness: &'static str,
1375 prefixes: Option<&'static str>,
1377 namespace: Option<String>,
1378 factor: String,
1379 offset: String,
1380 sources: Vec<String>,
1381 note: Option<String>,
1382 conditions: Option<String>,
1384 origin: Option<(String, usize)>,
1386 mapping: Option<(String, usize)>,
1388}
1389
1390fn unit_detail(
1391 reg: &Registry,
1392 user: &[ConfigUnit],
1393 mappings: &[Mapping],
1394 query: &str,
1395) -> Result<Detail, Error> {
1396 if let Some(u) = reg.get(query) {
1397 return Ok(Detail {
1398 queried: query.to_string(),
1399 id: u.id().to_string(),
1400 symbol: Some(u.symbol().to_string()),
1401 name: Some(u.name().to_string()),
1402 prefix: None,
1403 base: None,
1404 group: group_name(u.dim()),
1405 aliases: u.aliases().to_vec(),
1406 dim: u.dim(),
1407 quantity_kind: u.kind().name(),
1408 exactness: u.exactness().name(),
1409 prefixes: Some(u.prefixes().name()),
1410 namespace: Some(u.namespace().to_string()),
1411 factor: u.factor().to_fraction_string(),
1412 offset: u.offset().to_fraction_string(),
1413 sources: vec![u.source().to_string()],
1414 note: Some(u.note().to_string()),
1415 conditions: Some(u.conditions().to_string()).filter(|c| !c.is_empty()),
1416 origin: user_unit(user, u.id()).map(|c| (c.origin.clone(), c.line)),
1417 mapping: mappings
1418 .iter()
1419 .find(|m| m.name == query)
1420 .map(|m| (m.origin.clone(), m.line)),
1421 });
1422 }
1423 let e = reg.parse(query)?;
1426 let split = reg.split_prefixed(query);
1428 Ok(Detail {
1429 queried: query.to_string(),
1430 id: query.to_string(),
1431 symbol: split.map(|(p, u)| format!("{}{}", p.display_symbol(), u.symbol())),
1432 name: split.map(|(p, u)| p.compose_name(u.name())),
1433 prefix: split.map(|(p, _)| {
1434 (
1435 p.display_symbol().to_string(),
1436 p.name.to_string(),
1437 p.exp,
1438 p.family.name(),
1439 )
1440 }),
1441 base: split.map(|(_, u)| {
1442 (
1443 u.id().to_string(),
1444 u.symbol().to_string(),
1445 u.name().to_string(),
1446 )
1447 }),
1448 group: group_name(e.dim()),
1449 aliases: Vec::new(),
1450 dim: e.dim(),
1451 quantity_kind: e.kind().name(),
1452 exactness: e.exactness().name(),
1453 prefixes: None,
1454 namespace: None,
1455 factor: e.factor().to_fraction_string(),
1456 offset: e.offset().to_fraction_string(),
1457 sources: e.sources().to_vec(),
1458 note: None,
1459 conditions: None,
1460 origin: None,
1461 mapping: None,
1462 })
1463}
1464
1465fn detail_text(d: &Detail) -> String {
1466 let none = |s: &str| {
1467 if s.is_empty() {
1468 "-".to_string()
1469 } else {
1470 s.to_string()
1471 }
1472 };
1473 let mut s = format!("{}\n", d.id);
1474 let mut line = |k: &str, v: String| {
1475 let _ = writeln!(s, " {k:<14}{}", cell(&v));
1476 };
1477 if let Some(n) = d.symbol.as_ref().filter(|n| **n != d.id) {
1479 line("symbol", n.clone());
1480 }
1481 if let Some(n) = d.name.as_ref().filter(|n| **n != d.id) {
1482 line("name", n.clone());
1483 }
1484 if let Some((sym, name, exp, family)) = &d.prefix {
1485 let base = if *family == "binary" { 2 } else { 10 };
1486 line("prefix", format!("{sym} ({name}, {base}^{exp})"));
1487 }
1488 if let Some((id, sym, name)) = &d.base {
1489 line("base unit", format!("{id} ({sym}, {name})"));
1490 }
1491 line("group", d.group.clone());
1492 line("aliases", none(&d.aliases.join(", ")));
1493 line("dimension", d.dim.to_string());
1494 line("quantity kind", d.quantity_kind.to_string());
1495 line("exactness", d.exactness.to_string());
1496 if let Some(p) = d.prefixes {
1497 line("prefixes", p.to_string());
1498 }
1499 if let Some(n) = &d.namespace {
1500 line("namespace", n.clone());
1501 }
1502 line("factor to SI", d.factor.clone());
1503 line("offset to SI", d.offset.clone());
1504 if let Some(c) = &d.conditions {
1505 line("conditions", c.clone());
1506 }
1507 for src in &d.sources {
1508 line("source", src.clone());
1509 }
1510 if let Some(n) = d.note.as_deref().filter(|n| !n.is_empty()) {
1511 line("note", n.to_string());
1512 }
1513 if let Some((file, at)) = &d.mapping {
1514 line(
1515 "your name",
1516 format!(
1517 "'{query}' means this unit in your config ({file}:{at})",
1518 query = d.queried
1519 ),
1520 );
1521 }
1522 if let Some((file, at)) = &d.origin {
1523 line(
1524 "defined in",
1525 format!("{file}:{at} (your config, not the shipped table)"),
1526 );
1527 }
1528 s
1529}
1530
1531fn detail_json(d: &Detail) -> String {
1532 let list = |v: &[String]| {
1533 v.iter()
1534 .map(|x| json_string(x))
1535 .collect::<Vec<_>>()
1536 .join(",")
1537 };
1538 let opt = |v: Option<&str>| v.map_or_else(|| "null".to_string(), json_string);
1539 let dims: Vec<String> = d.dim.exponents().iter().map(ToString::to_string).collect();
1540 format!(
1541 "{{\"schema\":{},\"ok\":true,\"data_version\":{},\"unit\":{{\"id\":{},\"symbol\":{},\"name\":{},\"prefix\":{},\"base\":{},\"group\":{},\"aliases\":[{}],\"dimension\":[{}],\"quantity_kind\":{},\"exactness\":{},\"prefixes\":{},\"namespace\":{},\"factor\":{},\"offset\":{},\"sources\":[{}],\"conditions\":{},\"note\":{},\"origin\":{},\"mapping\":{}}}}}",
1542 json_string(&schema_id("units")),
1543 json_string(&dimvexa::data_version()),
1544 json_string(&d.id),
1545 opt(d.symbol.as_deref()),
1546 opt(d.name.as_deref()),
1547 d.prefix.as_ref().map_or_else(
1548 || "null".to_string(),
1549 |(sym, name, exp, family)| format!(
1550 "{{\"symbol\":{},\"name\":{},\"exp\":{exp},\"family\":{}}}",
1551 json_string(sym),
1552 json_string(name),
1553 json_string(family)
1554 ),
1555 ),
1556 d.base.as_ref().map_or_else(
1557 || "null".to_string(),
1558 |(id, sym, name)| format!(
1559 "{{\"id\":{},\"symbol\":{},\"name\":{}}}",
1560 json_string(id),
1561 json_string(sym),
1562 json_string(name)
1563 ),
1564 ),
1565 json_string(&d.group),
1566 list(&d.aliases),
1567 dims.join(","),
1568 json_string(d.quantity_kind),
1569 json_string(d.exactness),
1570 opt(d.prefixes),
1571 opt(d.namespace.as_deref()),
1572 json_string(&d.factor),
1573 json_string(&d.offset),
1574 list(&d.sources),
1575 opt(d.conditions.as_deref()),
1576 opt(d.note.as_deref()),
1577 d.origin.as_ref().map_or_else(
1578 || "null".to_string(),
1579 |(f, l)| format!("{{\"file\":{},\"line\":{l}}}", json_string(f)),
1580 ),
1581 d.mapping.as_ref().map_or_else(
1582 || "null".to_string(),
1583 |(f, l)| {
1584 format!(
1585 "{{\"name\":{},\"file\":{},\"line\":{l}}}",
1586 json_string(&d.queried),
1587 json_string(f)
1588 )
1589 },
1590 ),
1591 )
1592}
1593
1594fn run_units(
1596 target: &UnitsTarget,
1597 config_files: &[String],
1598 json: bool,
1599 out: &mut dyn Write,
1600 err: &mut dyn Write,
1601) -> u8 {
1602 let loaded = match load(config_files) {
1603 Ok(l) => l,
1604 Err(e) => return report_load_error(&e, json, "units", out, err),
1605 };
1606 let mut fail = |code: &str, message: &str, suggestions: &[String], status: u8| -> u8 {
1607 if json {
1608 let _ = writeln!(
1609 out,
1610 "{}",
1611 json_error("units", code, message, &[], suggestions)
1612 );
1613 } else {
1614 let _ = writeln!(err, "error[{code}]: {message}");
1615 }
1616 status
1617 };
1618 let reg = &loaded.reg;
1619 let groups = group_counts(reg);
1620 let (selected, group) = match target {
1621 UnitsTarget::Groups => {
1622 let text = if json {
1623 units_json(&groups, &[], &[], None, &loaded)
1624 } else {
1625 groups_text(&groups)
1626 };
1627 let _ = write!(out, "{text}");
1628 if json {
1629 let _ = writeln!(out);
1630 }
1631 return 0;
1632 }
1633 UnitsTarget::All => (None, None),
1634 UnitsTarget::Query(q) => {
1635 let q_lower = q.to_ascii_lowercase();
1636 if groups.iter().any(|(g, _)| *g == q_lower) {
1637 (Some(q_lower.clone()), Some(q_lower))
1638 } else {
1639 return match unit_detail(reg, &loaded.units, &loaded.mappings, q) {
1640 Ok(d) => {
1641 let text = if json {
1642 detail_json(&d)
1643 } else {
1644 detail_text(&d)
1645 };
1646 let _ = write!(out, "{text}");
1647 if json {
1648 let _ = writeln!(out);
1649 }
1650 0
1651 }
1652 Err(e) => {
1653 let hint = if e.code() == "unknown_unit" {
1654 "; it is not a group either (see 'dimvexa units --groups')".to_string()
1655 } else {
1656 String::new()
1657 };
1658 let mut near = e.candidates().to_vec();
1659 for sug in e.suggestions() {
1660 if !near.contains(sug) {
1661 near.push(sug.clone());
1662 }
1663 }
1664 fail(e.code(), &format!("{e}{hint}"), &near, 1)
1665 }
1666 };
1667 }
1668 }
1669 };
1670 let units: Vec<(String, &Unit)> = reg
1671 .units()
1672 .iter()
1673 .map(|u| (group_name(u.dim()), u))
1674 .filter(|(g, _)| selected.as_ref().is_none_or(|want| g == want))
1675 .collect();
1676 let ambiguous = select_ambiguous(&units, group.is_some());
1677 if json {
1678 let _ = writeln!(
1679 out,
1680 "{}",
1681 units_json(&groups, &units, &ambiguous, group.as_deref(), &loaded)
1682 );
1683 } else {
1684 let _ = write!(
1685 out,
1686 "{}",
1687 units_text(
1688 &units,
1689 &ambiguous,
1690 &loaded.mappings,
1691 &loaded.densities,
1692 &loaded.units,
1693 )
1694 );
1695 }
1696 0
1697}
1698
1699fn configs_list_text() -> String {
1701 let mut s = String::from("# columns: name, names, units, densities, hash\n");
1702 for b in dimvexa_config::BUNDLED {
1703 match b.config() {
1704 Ok(c) => {
1705 let _ = writeln!(
1706 s,
1707 "{}\t{}\t{}\t{}\t{}",
1708 b.name,
1709 c.mappings().len(),
1710 c.units().len(),
1711 c.constants().len(),
1712 c.hash()
1713 );
1714 }
1715 Err(e) => {
1716 let _ = writeln!(s, "{}\terror: {e}", b.name);
1717 }
1718 }
1719 }
1720 s
1721}
1722
1723fn configs_list_json() -> String {
1724 let items: Vec<String> = dimvexa_config::BUNDLED
1725 .iter()
1726 .filter_map(|b| {
1727 let c = b.config().ok()?;
1728 Some(format!(
1729 "{{\"name\":{},\"hash\":{},\"mappings\":{},\"units\":{},\"densities\":{}}}",
1730 json_string(b.name),
1731 json_string(c.hash()),
1732 c.mappings().len(),
1733 c.units().len(),
1734 c.constants().len()
1735 ))
1736 })
1737 .collect();
1738 format!(
1739 "{{\"schema\":{},\"ok\":true,\"configs\":[{}]}}",
1740 json_string(&schema_id("configs")),
1741 items.join(",")
1742 )
1743}
1744
1745fn config_detail_text(c: &Config) -> String {
1747 let mut s = format!("{} (hash {})\n", cell(c.origin()), c.hash());
1748 if !c.mappings().is_empty() {
1749 s.push_str(
1750 "\n## names (what they mean once the config is loaded)\n# columns: name, unit\n",
1751 );
1752 for m in c.mappings() {
1753 let _ = writeln!(s, "{}\t{}", cell(&m.name), cell(&m.target));
1754 }
1755 }
1756 if !c.units().is_empty() {
1757 s.push_str("\n## units\n# columns: symbol, base, factor, exactness, source\n");
1758 for u in c.units() {
1759 let _ = writeln!(
1760 s,
1761 "{}\t{}\t{}\t{}\t{}",
1762 cell(&u.symbol),
1763 cell(&u.base),
1764 u.factor.to_fraction_string(),
1765 u.exactness.name(),
1766 cell(&u.source)
1767 );
1768 }
1769 }
1770 if !c.constants().is_empty() {
1771 s.push_str(
1772 "\n## densities (use with --density NAME)\n# columns: name, value, unit, conditions, source\n",
1773 );
1774 for d in c.constants() {
1775 let _ = writeln!(
1776 s,
1777 "{}\t{}\t{}\t{}\t{}",
1778 cell(&d.name),
1779 d.value.to_fraction_string(),
1780 d.kind.unit(),
1781 cell(&d.conditions),
1782 cell(&d.source)
1783 );
1784 }
1785 }
1786 s
1787}
1788
1789fn config_detail_json(c: &Config) -> String {
1790 let mappings: Vec<String> = c
1791 .mappings()
1792 .iter()
1793 .map(|m| {
1794 format!(
1795 "{{\"name\":{},\"unit\":{},\"line\":{}}}",
1796 json_string(&m.name),
1797 json_string(&m.target),
1798 m.line
1799 )
1800 })
1801 .collect();
1802 let units: Vec<String> = c
1803 .units()
1804 .iter()
1805 .map(|u| {
1806 format!(
1807 "{{\"symbol\":{},\"base\":{},\"factor\":{},\"exactness\":{},\"source\":{},\"line\":{}}}",
1808 json_string(&u.symbol),
1809 json_string(&u.base),
1810 json_string(&u.factor.to_fraction_string()),
1811 json_string(u.exactness.name()),
1812 json_string(&u.source),
1813 u.line
1814 )
1815 })
1816 .collect();
1817 let densities: Vec<String> = c.constants().iter().map(density_json).collect();
1818 format!(
1819 "{{\"schema\":{},\"ok\":true,\"config\":{{\"name\":{},\"hash\":{},\"mappings\":[{}],\"units\":[{}],\"densities\":[{}]}}}}",
1820 json_string(&schema_id("configs")),
1821 json_string(c.origin()),
1822 json_string(c.hash()),
1823 mappings.join(","),
1824 units.join(","),
1825 densities.join(",")
1826 )
1827}
1828
1829fn run_configs(target: Option<&str>, json: bool, out: &mut dyn Write, err: &mut dyn Write) -> u8 {
1831 let text = match target {
1832 None if json => configs_list_json(),
1833 None => configs_list_text(),
1834 Some(spec) => match read_spec(spec) {
1835 Ok(c) if json => config_detail_json(&c),
1836 Ok(c) => config_detail_text(&c),
1837 Err(e) => {
1838 let suggestions: Vec<String> = if matches!(e, LoadError::Io(_)) {
1839 dimvexa_config::BUNDLED
1840 .iter()
1841 .map(|b| b.name.to_string())
1842 .collect()
1843 } else {
1844 Vec::new()
1845 };
1846 let (code, message, status) = match &e {
1847 LoadError::Io(m) => ("usage", m.clone(), 2),
1848 LoadError::Lib(e) => (e.code(), e.to_string(), 1),
1849 };
1850 if json {
1851 let _ = writeln!(
1852 out,
1853 "{}",
1854 json_error("configs", code, &message, &[], &suggestions)
1855 );
1856 } else {
1857 let _ = writeln!(err, "error[{code}]: {message}");
1858 }
1859 return status;
1860 }
1861 },
1862 };
1863 let _ = write!(out, "{text}");
1864 if json {
1865 let _ = writeln!(out);
1866 }
1867 0
1868}
1869
1870pub fn run<I>(args: I, out: &mut dyn Write, err: &mut dyn Write) -> u8
1873where
1874 I: IntoIterator<Item = String>,
1875{
1876 run_with(args, out, err, Color::default())
1877}
1878
1879pub fn run_with<I>(args: I, out: &mut dyn Write, err: &mut dyn Write, color: Color) -> u8
1881where
1882 I: IntoIterator<Item = String>,
1883{
1884 let mut args: Vec<String> = args.into_iter().collect();
1885 let mut color = color;
1886 match take_color_option(&mut args) {
1887 Ok(Some(when)) => {
1888 color = match when {
1889 ColorWhen::Always => Color {
1890 out: true,
1891 err: true,
1892 },
1893 ColorWhen::Never => Color {
1894 out: false,
1895 err: false,
1896 },
1897 ColorWhen::Auto => color,
1898 };
1899 }
1900 Ok(None) => {}
1901 Err(message) => {
1902 if args.iter().any(|a| a == "--json" || a == "--pretty") {
1903 let line = json_error(command_schema(&args), "usage", &message, &[], &[]);
1904 let _ = writeln!(out, "{line}");
1905 } else {
1906 let _ = writeln!(err, "error[usage]: {message}\n\n{USAGE}");
1907 }
1908 return 2;
1909 }
1910 }
1911 if args.iter().any(|a| a == "--pretty") {
1912 args.retain(|a| a != "--pretty");
1914 if !args.iter().any(|a| a == "--json") {
1915 args.push("--json".to_string());
1916 }
1917 let mut buffered = Vec::new();
1918 let code = run_with(args, &mut buffered, err, color);
1919 let text = String::from_utf8_lossy(&buffered);
1920 for line in text.lines() {
1921 let _ = writeln!(out, "{}", pretty_json(line));
1922 }
1923 return code;
1924 }
1925 if !color.err {
1926 return run_inner(args, out, err, color);
1927 }
1928 let mut buffered = Vec::new();
1929 let code = run_inner(args, out, &mut buffered, color);
1930 let text = String::from_utf8_lossy(&buffered);
1931 let _ = write!(err, "{}", colorize_diagnostics(&text));
1932 code
1933}
1934
1935fn report_usage_error(u: &UsageError, out: &mut dyn Write, err: &mut dyn Write) -> u8 {
1937 if u.json {
1938 let _ = writeln!(
1939 out,
1940 "{}",
1941 json_error(u.schema, "usage", &u.message, &[], &u.suggestions)
1942 );
1943 } else {
1944 let _ = writeln!(err, "error[usage]: {}\n\n{USAGE}", u.message);
1945 }
1946 2
1947}
1948
1949fn run_inner<I>(args: I, out: &mut dyn Write, err: &mut dyn Write, color: Color) -> u8
1950where
1951 I: IntoIterator<Item = String>,
1952{
1953 let args: Vec<String> = args.into_iter().collect();
1954 let (parsed, config_files) = match parse_args(&args) {
1955 Ok(p) => p,
1956 Err(u) => return report_usage_error(&u, out, err),
1957 };
1958 let args = match parsed {
1959 Parsed::Help => {
1960 let _ = write!(out, "{}", help_text());
1961 return 0;
1962 }
1963 Parsed::Version => {
1964 let _ = writeln!(
1965 out,
1966 "{} {} (data {})",
1967 dimvexa::PROJECT_NAME,
1968 env!("CARGO_PKG_VERSION"),
1969 dimvexa::data_version()
1970 );
1971 let _ = write!(out, "{}", about());
1972 return 0;
1973 }
1974 Parsed::Units { target, json } => {
1975 return run_units(&target, &config_files, json, out, err);
1976 }
1977 Parsed::Configs { target, json } => {
1978 return run_configs(target.as_deref(), json, out, err);
1979 }
1980 Parsed::Update { check, yes } => return update::run(check, yes, out, err),
1981 Parsed::Convert(a) => a,
1982 };
1983 let loaded = match load(&config_files) {
1984 Ok(l) => l,
1985 Err(e) => return report_load_error(&e, args.json, "convert", out, err),
1986 };
1987 let density = match &args.density {
1988 None => None,
1989 Some(spec) => match resolve_density(spec, &loaded) {
1990 Ok(d) => Some(d),
1991 Err(e) => {
1992 let (code, message, candidates, suggestions, status) = match &e {
1993 DensityError::Usage(m, known) => {
1994 ("usage", m.clone(), Vec::new(), known.clone(), 2)
1995 }
1996 DensityError::Lib(e) => (
1997 e.code(),
1998 format!("--density: {e}"),
1999 e.candidates().to_vec(),
2000 e.suggestions().to_vec(),
2001 1,
2002 ),
2003 };
2004 if args.json {
2005 let line = json_error("convert", code, &message, &candidates, &suggestions);
2006 let _ = writeln!(out, "{line}");
2007 } else {
2008 let _ = writeln!(err, "error[{code}]: {message}");
2009 }
2010 return status;
2011 }
2012 },
2013 };
2014 match compute(&args, &loaded, density.as_ref()) {
2015 Ok(outcome) => {
2016 let text = if args.json {
2017 match render_json(&outcome) {
2018 Ok(t) => t,
2019 Err(e) => return fail(&args, &e, out, err),
2020 }
2021 } else {
2022 render_text(&outcome, color.out)
2023 };
2024 let _ = writeln!(out, "{text}");
2025 if !args.json {
2026 match (outcome.density, &args.density) {
2027 (Some(b), _) => {
2028 let _ = writeln!(err, "{}", density_note(b));
2029 }
2030 (None, Some(spec)) => {
2031 let _ = writeln!(
2032 err,
2033 "note: density {spec} was not needed: both units have the same dimension"
2034 );
2035 }
2036 (None, None) => {}
2037 }
2038 }
2039 0
2040 }
2041 Err(e) => fail(&args, &e, out, err),
2042 }
2043}
2044
2045fn fail(args: &Args, e: &Error, out: &mut dyn Write, err: &mut dyn Write) -> u8 {
2046 if args.json {
2047 let _ = writeln!(
2048 out,
2049 "{}",
2050 json_error(
2051 "convert",
2052 e.code(),
2053 &e.to_string(),
2054 e.candidates(),
2055 e.suggestions()
2056 )
2057 );
2058 } else {
2059 let _ = writeln!(err, "error[{}]: {e}", e.code());
2060 if let Error::DimensionMismatch { from, to } = e {
2061 let bare = |w: &str| matches!(w, "F" | "C");
2062 for (word, other, other_word) in
2063 [(&args.to, *from, &args.from), (&args.from, *to, &args.to)]
2064 {
2065 if bare(word) && (other == Dim::K || bare(other_word)) {
2068 let (what, fix) = if word == "F" {
2069 ("farad", "degF or °F")
2070 } else {
2071 ("coulomb", "degC or °C")
2072 };
2073 let _ = writeln!(
2074 err,
2075 "hint: '{word}' alone is the {what}, not a temperature scale; for the scale write {fix}"
2076 );
2077 }
2078 }
2079 if Constant::density_needed(*from, *to) && args.density.is_none() {
2080 let _ = writeln!(
2081 err,
2082 "hint: mass and volume need a density. Pass your own number, for example \
2083--density 998.2067kg/m3, or a named one: --config water --density water_25C \
2084(docs/adr/014-one-config-mechanism.md)"
2085 );
2086 }
2087 }
2088 }
2089 1
2090}
2091
2092#[cfg(test)]
2093mod tests {
2094 use super::*;
2095
2096 fn run_str(args: &[&str]) -> (u8, String, String) {
2097 let (mut out, mut err) = (Vec::new(), Vec::new());
2098 let code = run(args.iter().map(ToString::to_string), &mut out, &mut err);
2099 (
2100 code,
2101 String::from_utf8(out).unwrap(),
2102 String::from_utf8(err).unwrap(),
2103 )
2104 }
2105
2106 #[test]
2107 fn escapes_json() {
2108 assert_eq!(json_string("a\"b\\c\n\u{1}é"), "\"a\\\"b\\\\c\\n\\u0001é\"");
2109 }
2110
2111 #[test]
2112 fn f64_text_forms() {
2113 assert_eq!(f64_text(36.0), "36");
2114 assert_eq!(f64_text(0.0), "0");
2115 assert_eq!(f64_text(1e300), "1e300");
2116 assert_eq!(f64_text(2.5e-7), "2.5e-7");
2117 assert_eq!(f64_text(-0.5), "-0.5");
2118 }
2119
2120 #[test]
2121 fn basic_conversion_and_negative_numbers() {
2122 assert_eq!(
2123 run_str(&["convert", "10", "m/s", "km/h"]),
2124 (0, "36 km/h\n".into(), String::new())
2125 );
2126 assert_eq!(run_str(&["-40", "degF", "degC"]).1, "-40 degC\n");
2127 }
2128
2129 #[test]
2130 fn units_lists_the_table() {
2131 let (code, out, _) = run_str(&["units"]);
2132 assert_eq!(code, 0);
2133 assert!(out.lines().any(|l| l == "## pressure"));
2134 assert!(out.lines().any(|l| l.starts_with("psi\t")));
2135 assert!(out.lines().any(|l| l.starts_with("ton\t")));
2136 let (code, out, _) = run_str(&["units", "--groups"]);
2137 assert_eq!(code, 0);
2138 assert!(out.lines().any(|l| l == "volume\t15"));
2139 let (code, out, _) = run_str(&["units", "psi"]);
2140 assert_eq!(code, 0);
2141 assert!(out.contains("group pressure"));
2142 let (code, out, _) = run_str(&["units", "volume"]);
2143 assert_eq!(code, 0);
2144 assert!(out.contains("gal_us\t") && !out.contains("psi\t"));
2145 assert!(out.contains("\ngal\t"), "ambiguous gal belongs to volume");
2146 let (code, out, _) = run_str(&["units", "--json"]);
2147 assert_eq!(code, 0);
2148 assert!(out.contains("\"schema\":\"dimvexa.units/3\""));
2149 assert_eq!(run_str(&["units", "--exact"]).0, 2);
2150 assert_eq!(run_str(&["units", "nope"]).0, 1);
2151 assert_eq!(run_str(&["units", "--groups", "psi"]).0, 2);
2152 assert_eq!(run_str(&["1", "m", "km", "--groups"]).0, 2);
2153 assert_eq!(run_str(&["units", "length", "mass"]).0, 2);
2154 assert_eq!(run_str(&["convert", "units"]).0, 2);
2155 }
2156
2157 #[test]
2158 fn forgives_degree_spacing_and_to() {
2159 for args in [
2160 ["0", "°C", "°F"].as_slice(),
2161 &["0", "°C", "°", "F"],
2162 &["0", "°C", "to", "°F"],
2163 &["convert", "0", "°C", "to", "°", "F"],
2164 ] {
2165 assert_eq!(
2166 run_str(args),
2167 (0, "32 °F\n".into(), String::new()),
2168 "{args:?}"
2169 );
2170 }
2171 }
2172
2173 #[test]
2174 fn bare_f_after_a_temperature_hints_at_degf() {
2175 let (code, _, err) = run_str(&["0", "°C", "F"]);
2176 assert_eq!(code, 1);
2177 assert!(err.contains("'F' alone is the farad"), "{err}");
2178 assert!(err.contains("write degF or °F"), "{err}");
2179 }
2180
2181 #[test]
2182 fn normalisation_edges() {
2183 assert_eq!(run_str(&["0", "°C", "º", "F"]).1, "32 °F\n");
2185 assert_eq!(run_str(&["180", "°", "rad"]).0, 0);
2186 assert_eq!(run_str(&["0", "°C", "to"]).0, 1);
2187 assert_eq!(run_str(&["units", "°", "F"]).0, 2);
2188 }
2189
2190 #[test]
2191 fn scale_hints_cover_both_sides() {
2192 let hint = |a: &[&str]| run_str(a).2;
2193 assert!(hint(&["32", "F", "°C"]).contains("'F' alone is the farad"));
2194 assert!(hint(&["0", "°C", "C"]).contains("'C' alone is the coulomb"));
2195 assert!(hint(&["0", "C", "F"]).contains("write degC or °C"));
2196 assert!(hint(&["0", "C", "F"]).contains("write degF or °F"));
2197 assert!(!hint(&["1", "m", "F"]).contains("hint:"));
2198 }
2199
2200 #[test]
2201 fn diagnostics_colour_labels_only() {
2202 let text = "error[usage]: x\nhint: y\nnote: z\nerror[ broken\nplain\n";
2203 let c = colorize_diagnostics(text);
2204 assert!(c.contains("\x1b[1;31merror[usage]\x1b[0m: x"));
2205 assert!(c.contains("\x1b[36mhint:\x1b[0m y"));
2206 assert!(c.contains("\x1b[33mnote:\x1b[0m z"));
2207 assert!(c.contains("error[ broken\nplain\n"));
2208 }
2209
2210 #[test]
2211 fn pretty_passes_help_and_version_through() {
2212 assert_eq!(run_str(&["--help", "--pretty"]).1, run_str(&["--help"]).1);
2213 assert_eq!(
2214 run_str(&["--version", "--pretty"]).1,
2215 run_str(&["--version"]).1
2216 );
2217 let both = run_str(&["1", "m", "km", "--pretty", "--json"]).1;
2218 assert_eq!(both, run_str(&["1", "m", "km", "--pretty"]).1);
2219 let (_, units, _) = run_str(&["units", "gal", "--pretty"]);
2220 assert!(units.starts_with("{\n"), "{units}");
2221 }
2222
2223 #[test]
2224 fn color_option_overrides_the_default() {
2225 let run = |a: &[&str], c: Color| {
2226 let (mut out, mut err) = (Vec::new(), Vec::new());
2227 let code = run_with(a.iter().map(ToString::to_string), &mut out, &mut err, c);
2228 (
2229 code,
2230 String::from_utf8(out).unwrap(),
2231 String::from_utf8(err).unwrap(),
2232 )
2233 };
2234 let on = Color {
2235 out: true,
2236 err: true,
2237 };
2238 let off = Color::default();
2239 assert!(
2240 run(&["1", "m", "km", "--color=always"], off)
2241 .1
2242 .contains('\x1b')
2243 );
2244 assert!(
2245 run(&["--color", "always", "1", "m", "km"], off)
2246 .1
2247 .contains('\x1b')
2248 );
2249 assert!(
2250 !run(&["1", "m", "km", "--color=never"], on)
2251 .1
2252 .contains('\x1b')
2253 );
2254 assert!(
2255 run(&["1", "m", "km", "--color=auto"], on)
2256 .1
2257 .contains('\x1b')
2258 );
2259 assert!(
2260 !run(&["1", "m", "km", "--color=always", "--json"], off)
2261 .1
2262 .contains('\x1b')
2263 );
2264 let (code, _, err) = run(&["1", "m", "km", "--color=sometimes"], off);
2265 assert_eq!(code, 2);
2266 assert!(err.contains("auto, always or never"));
2267 assert_eq!(run(&["1", "m", "km", "--color"], off).0, 2);
2268 }
2269
2270 #[test]
2271 fn color_only_where_asked() {
2272 let (mut out, mut err) = (Vec::new(), Vec::new());
2273 let on = Color {
2274 out: true,
2275 err: true,
2276 };
2277 let args = ["0", "°C", "F"].map(String::from);
2278 run_with(args, &mut out, &mut err, on);
2279 assert!(
2280 String::from_utf8(err)
2281 .unwrap()
2282 .contains("\x1b[1;31merror[dimension_mismatch]\x1b[0m")
2283 );
2284 let args = ["0", "degC", "degF"].map(String::from);
2285 let mut out = Vec::new();
2286 run_with(args, &mut out, &mut Vec::new(), on);
2287 assert_eq!(
2288 String::from_utf8(out).unwrap(),
2289 "\x1b[1;32m32\x1b[0m \x1b[36mdegF\x1b[0m\n"
2290 );
2291 let (_, json, _) = run_str(&["0", "degC", "degF", "--json"]);
2292 assert!(!json.contains('\x1b'));
2293 }
2294
2295 #[test]
2296 fn pretty_is_json_with_indentation() {
2297 let (code, out, _) = run_str(&["100", "degC", "degF", "--pretty"]);
2298 assert_eq!(code, 0);
2299 assert!(
2300 out.contains("\n \"output\": {\n \"value\": \"212\""),
2301 "{out}"
2302 );
2303 assert!(
2304 out.contains("\"dimension\": [0, 0, 0, 0, 1, 0, 0]"),
2305 "{out}"
2306 );
2307 let compact = run_str(&["100", "degC", "degF", "--json"]).1;
2308 assert_eq!(pretty_json(compact.trim()), out.trim_end());
2309 let (code, out, _) = run_str(&["1", "m", "--pretty"]);
2310 assert_eq!(code, 2);
2311 assert!(out.contains("\"code\": \"usage\""));
2312 }
2313
2314 #[test]
2315 fn usage_errors_exit_2() {
2316 assert_eq!(run_str(&[]).0, 2);
2317 assert_eq!(run_str(&["convert", "1", "m"]).0, 2);
2318 assert_eq!(run_str(&["1", "m", "km", "--nope"]).0, 2);
2319 let (code, out, _) = run_str(&["1", "m", "--json"]);
2320 assert_eq!(code, 2);
2321 assert!(out.contains("\"code\":\"usage\""));
2322 }
2323}