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