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
29const USAGE: &str = "\
30Usage:
31 dimvexa convert VALUE FROM TO [--exact] [--json] [--config NAME_OR_PATH]... [--density NAME_OR_VALUE]
32 dimvexa VALUE FROM TO [--exact] [--json] [--config NAME_OR_PATH]... [--density NAME_OR_VALUE]
33 dimvexa units [--groups | GROUP | UNIT] [--json] [--config NAME_OR_PATH]...
34 dimvexa configs [NAME_OR_PATH] [--json]
35
36Converts VALUE from unit FROM to unit TO.
37
38Commands:
39 units list the supported units grouped by quantity, and the ambiguous symbols
40 that are refused
41 units --groups list only the groups (length, mass, volume, ...) with their sizes
42 units GROUP list one group, for example: dimvexa units volume
43 units UNIT show the details of one unit, for example: dimvexa units psi
44 configs list the configs that ship with dimvexa (us, gb, ...)
45 configs NAME show what one config contains: names, units and densities with their
46 conditions and sources; a path shows your own file
47
48Options:
49 --exact print the exact rational result (num/den) instead of a decimal
50 --json machine-readable output (schema dimvexa.convert/2; dimvexa.units/2 for units,
51 dimvexa.configs/1 for configs)
52 --config NAME_OR_PATH (beta) apply a config (repeatable, later ones win; see
53 docs/adr/014-one-config-mechanism.md). A shipped one is named (us, gb,
54 metric-trade, year-julian, year-civil, water); a file has a path (./mine.toml,
55 or anything with a / or ending in .toml). Without --config the names stay strict
56 --density NAME_OR_VALUE use a density for mass <-> volume: a name from a loaded config, for
57 example --config water --density water_25C, or your own number, for example
58 --density 998.2067kg/m3. The assumption is printed with the result
59 --version print version and data table version
60 -h, --help print this help
61
62Examples:
63 dimvexa convert 10 m/s km/h
64 dimvexa convert 1 psi Pa --exact
65 dimvexa convert 100 degC degF --json
66 dimvexa convert 1 ton kg --config us
67 dimvexa convert 1 mt kg --config metric-trade
68 dimvexa convert 1 L kg --config water --density water_25C
69 dimvexa convert 1 L kg --density 998.2067kg/m3
70 dimvexa convert 1 bbl_oil L --config oil.toml
71
72Precision: the CLI always uses the exact path, so the number printed is the exact result
73rounded once to the nearest f64 (0 ulp). The library's fast f64 path is within 1 ulp (linear
74units) or 2 ulp (temperature scales such as degC and degF) of it; see docs/precision.md.
75
76Exit codes: 0 success, 1 conversion refused or invalid input, 2 usage error.
77";
78
79struct Args {
81 density: Option<String>,
83 value: String,
84 from: String,
85 to: String,
86 exact: bool,
87 json: bool,
88}
89
90enum UnitsTarget {
92 All,
94 Groups,
96 Query(String),
98}
99
100enum Parsed {
101 Convert(Args),
102 Units {
103 target: UnitsTarget,
104 json: bool,
105 },
106 Configs {
108 target: Option<String>,
109 json: bool,
110 },
111 Help,
112 Version,
113}
114
115struct UsageError {
117 message: String,
118 json: bool,
119 schema: &'static str,
121 suggestions: Vec<String>,
122}
123
124fn command_schema(args: &[String]) -> &'static str {
126 match args
127 .iter()
128 .find(|a| !a.starts_with('-'))
129 .map(String::as_str)
130 {
131 Some("units") => "units",
132 Some("configs") => "configs",
133 _ => "convert",
134 }
135}
136
137struct ValueOptions {
139 configs: Vec<String>,
140 density: Option<String>,
141}
142
143fn split_options(args: &[String]) -> Result<(ValueOptions, Vec<String>), UsageError> {
146 let mut opts = ValueOptions {
147 configs: Vec::new(),
148 density: None,
149 };
150 let mut rest = Vec::new();
151 let missing = |what: &str| UsageError {
152 message: format!("'{what}' needs a value"),
153 json: args.iter().any(|x| x == "--json"),
154 schema: command_schema(args),
155 suggestions: Vec::new(),
156 };
157 let mut it = args.iter();
158 while let Some(a) = it.next() {
159 let (name, inline) = match a.split_once('=') {
160 Some((n @ ("--config" | "--density"), v)) => (n, Some(v.to_string())),
161 _ => (a.as_str(), None),
162 };
163 match name {
164 "--bridge" => {
165 return Err(UsageError {
166 message:
167 "'--bridge' was renamed '--density' (ADR-014): use --density NAME_OR_VALUE"
168 .to_string(),
169 ..missing(name)
170 });
171 }
172 "--config" | "--density" => {
173 let value = match inline {
174 Some(v) => v,
175 None => it
177 .next()
178 .filter(|v| !v.starts_with("--"))
179 .cloned()
180 .ok_or_else(|| missing(name))?,
181 };
182 if name == "--config" {
183 opts.configs.push(value);
184 } else if opts.density.replace(value).is_some() {
185 return Err(UsageError {
186 message: "'--density' is given twice; give one density".to_string(),
187 ..missing(name)
188 });
189 }
190 }
191 _ => rest.push(a.clone()),
192 }
193 }
194 Ok((opts, rest))
195}
196
197fn parse_units(
199 rest: &[&str],
200 want_groups: bool,
201 json: bool,
202 configs: Vec<String>,
203 usage: &dyn Fn(String) -> UsageError,
204) -> Result<(Parsed, Vec<String>), UsageError> {
205 match (rest, want_groups) {
206 ([], false) => Ok((
207 Parsed::Units {
208 target: UnitsTarget::All,
209 json,
210 },
211 configs,
212 )),
213 ([], true) => Ok((
214 Parsed::Units {
215 target: UnitsTarget::Groups,
216 json,
217 },
218 configs,
219 )),
220 ([_], true) => Err(usage(
221 "'--groups' lists the groups and takes no GROUP or UNIT".to_string(),
222 )),
223 ([query], false) => Ok((
224 Parsed::Units {
225 target: UnitsTarget::Query((*query).to_string()),
226 json,
227 },
228 configs,
229 )),
230 _ => Err(usage(format!(
231 "'units' takes at most one argument (a group such as 'length' or a unit such as 'psi'), got {}",
232 rest.len()
233 ))),
234 }
235}
236
237fn parse_configs(
239 rest: &[&str],
240 json: bool,
241 configs: Vec<String>,
242 usage: &dyn Fn(String) -> UsageError,
243) -> Result<(Parsed, Vec<String>), UsageError> {
244 match rest {
245 [] => Ok((Parsed::Configs { target: None, json }, configs)),
246 [one] => Ok((
247 Parsed::Configs {
248 target: Some((*one).to_string()),
249 json,
250 },
251 configs,
252 )),
253 _ => Err(usage(format!(
254 "'configs' takes at most one argument (a name such as 'us' or a path), got {}",
255 rest.len()
256 ))),
257 }
258}
259
260fn parse_args(all: &[String]) -> Result<(Parsed, Vec<String>), UsageError> {
261 let (opts, args) = split_options(all)?;
262 let configs = opts.configs;
263 let args = args.as_slice();
264 let json = args.iter().any(|a| a == "--json");
265 let schema = command_schema(args);
266 let usage = |message: String| UsageError {
267 message,
268 json,
269 schema,
270 suggestions: Vec::new(),
271 };
272 let mut positional: Vec<&str> = Vec::new();
273 let (mut exact, mut want_json, mut want_groups) = (false, false, false);
274 for a in args {
275 match a.as_str() {
276 "--exact" => exact = true,
277 "--json" => want_json = true,
278 "--groups" => want_groups = true,
279 "-h" | "--help" => return Ok((Parsed::Help, configs)),
280 "-V" | "--version" => return Ok((Parsed::Version, configs)),
281 s if s.starts_with("--") => return Err(usage(format!("unknown option '{s}'"))),
282 s => positional.push(s),
283 }
284 }
285 if let Some((&"units", rest)) = positional.split_first() {
286 if exact {
287 return Err(usage("'units' does not take --exact".to_string()));
288 }
289 if opts.density.is_some() {
290 return Err(usage(
291 "'--density' selects a density for a conversion; 'units' lists them".to_string(),
292 ));
293 }
294 return parse_units(rest, want_groups, want_json, configs, &usage);
295 }
296 if let Some((&"configs", rest)) = positional.split_first() {
297 if exact || want_groups {
298 return Err(usage(
299 "'configs' takes only NAME_OR_PATH and --json".to_string(),
300 ));
301 }
302 if !configs.is_empty() || opts.density.is_some() {
303 return Err(usage(
304 "'configs' shows configs by name or path; it does not take --config or --density"
305 .to_string(),
306 ));
307 }
308 return parse_configs(rest, want_json, configs, &usage);
309 }
310 if want_groups {
311 return Err(usage("'--groups' only applies to 'units'".to_string()));
312 }
313 let density = opts.density;
314 if positional.first() == Some(&"convert") {
315 positional.remove(0);
316 }
317 match positional.as_slice() {
318 [value, from, to] => Ok((
319 Parsed::Convert(Args {
320 density,
321 value: (*value).to_string(),
322 from: (*from).to_string(),
323 to: (*to).to_string(),
324 exact,
325 json: want_json,
326 }),
327 configs,
328 )),
329 other => Err(usage(format!(
330 "expected VALUE FROM TO, got {} argument(s); try 'dimvexa --help'",
331 other.len()
332 ))),
333 }
334}
335
336fn json_string(s: &str) -> String {
338 let mut out = String::with_capacity(s.len().saturating_add(2));
339 out.push('"');
340 for c in s.chars() {
341 match c {
342 '"' => out.push_str("\\\""),
343 '\\' => out.push_str("\\\\"),
344 '\n' => out.push_str("\\n"),
345 '\r' => out.push_str("\\r"),
346 '\t' => out.push_str("\\t"),
347 c if u32::from(c) < 0x20 => {
348 let _ = write!(out, "\\u{:04x}", u32::from(c));
349 }
350 c => out.push(c),
351 }
352 }
353 out.push('"');
354 out
355}
356
357fn f64_text(x: f64) -> String {
360 let a = x.abs();
361 if a != 0.0 && !(1e-4..1e16).contains(&a) {
362 format!("{x:e}")
363 } else {
364 format!("{x}")
365 }
366}
367
368fn schema_id(command: &str) -> String {
370 let version = match command {
371 "configs" => 1,
372 _ => 2,
373 };
374 format!("{}.{command}/{version}", dimvexa::PROJECT_NAME)
375}
376
377fn json_error(
378 schema: &str,
379 code: &str,
380 message: &str,
381 candidates: &[String],
382 suggestions: &[String],
383) -> String {
384 let mut s = format!(
385 "{{\"schema\":{},\"ok\":false,\"error\":{{\"code\":{},\"message\":{}",
386 json_string(&schema_id(schema)),
387 json_string(code),
388 json_string(message)
389 );
390 if !candidates.is_empty() {
391 let list: Vec<String> = candidates.iter().map(|c| json_string(c)).collect();
392 let _ = write!(s, ",\"candidates\":[{}]", list.join(","));
393 }
394 if !suggestions.is_empty() {
395 let list: Vec<String> = suggestions.iter().map(|c| json_string(c)).collect();
396 let _ = write!(s, ",\"suggestions\":[{}]", list.join(","));
397 }
398 s.push_str("}}");
399 s
400}
401
402struct Loaded {
404 reg: Registry,
405 mappings: Vec<Mapping>,
407 units: Vec<ConfigUnit>,
409 densities: Vec<Constant>,
411 configs: Vec<(String, String)>,
414}
415
416enum LoadError {
418 Io(String),
420 Lib(Error),
422}
423
424const MAX_CONFIG_BYTES: u64 = 1 << 20;
427
428fn read_config(file: &str) -> Result<String, LoadError> {
430 use std::io::Read as _;
431 let fail = |why: String| LoadError::Io(format!("cannot read config '{file}': {why}"));
432 let meta = std::fs::metadata(file).map_err(|e| fail(e.to_string()))?;
433 if !meta.is_file() {
434 return Err(fail("not a regular file".to_string()));
435 }
436 let mut text = String::new();
437 std::fs::File::open(file)
438 .map_err(|e| fail(e.to_string()))?
439 .take(MAX_CONFIG_BYTES.saturating_add(1))
440 .read_to_string(&mut text)
441 .map_err(|e| fail(e.to_string()))?;
442 if u64::try_from(text.len()).map_or(true, |n| n > MAX_CONFIG_BYTES) {
443 return Err(fail(format!("larger than {MAX_CONFIG_BYTES} bytes")));
444 }
445 Ok(text)
446}
447
448fn read_spec(spec: &str) -> Result<Config, LoadError> {
451 if dimvexa_config::is_path(spec) {
452 let text = read_config(spec)?;
453 return Config::parse(&text, spec).map_err(LoadError::Lib);
454 }
455 dimvexa_config::bundled(spec).map_or_else(
456 || {
457 Err(LoadError::Io(format!(
458 "no config named '{spec}'; shipped configs: {} (a file needs a path, for example ./{spec} or {spec}.toml)",
459 bundled_names()
460 )))
461 },
462 |b| b.config().map_err(LoadError::Lib),
463 )
464}
465
466fn bundled_names() -> String {
467 dimvexa_config::BUNDLED
468 .iter()
469 .map(|b| b.name)
470 .collect::<Vec<_>>()
471 .join(", ")
472}
473
474fn load(config_specs: &[String]) -> Result<Loaded, LoadError> {
475 let mut reg = registry().map_err(LoadError::Lib)?;
476 let (mut parsed, mut configs) = (Vec::new(), Vec::new());
477 for spec in config_specs {
478 let cfg = read_spec(spec)?;
479 configs.push((cfg.origin().to_string(), cfg.hash().to_string()));
480 parsed.push(cfg);
481 }
482 let applied = dimvexa_config::apply(&mut reg, &parsed).map_err(LoadError::Lib)?;
483 let densities = dimvexa_config::constants(&parsed);
484 Ok(Loaded {
485 reg,
486 mappings: applied.mappings,
487 units: applied.units,
488 densities,
489 configs,
490 })
491}
492
493fn report_load_error(
495 e: &LoadError,
496 json: bool,
497 schema: &str,
498 out: &mut dyn Write,
499 err: &mut dyn Write,
500) -> u8 {
501 let (code, message, status) = match e {
502 LoadError::Io(m) => ("usage", m.clone(), 2),
503 LoadError::Lib(e) => (e.code(), e.to_string(), 1),
504 };
505 if json {
506 let _ = writeln!(out, "{}", json_error(schema, code, &message, &[], &[]));
507 } else {
508 let _ = writeln!(err, "error[{code}]: {message}");
509 }
510 status
511}
512
513fn configs_json(configs: &[(String, String)]) -> String {
515 let items: Vec<String> = configs
516 .iter()
517 .map(|(f, h)| {
518 format!(
519 "{{\"file\":{},\"hash\":{}}}",
520 json_string(f),
521 json_string(h)
522 )
523 })
524 .collect();
525 format!("[{}]", items.join(","))
526}
527
528struct Outcome<'a> {
530 args: &'a Args,
531 configs: &'a [(String, String)],
532 density: Option<&'a Constant>,
534 from: UnitExpr,
535 to: UnitExpr,
536 conv: Conversion,
537 exact: Ratio,
538 approx: f64,
540}
541
542fn compute<'a>(
543 args: &'a Args,
544 loaded: &'a Loaded,
545 density: Option<&'a Constant>,
546) -> Result<Outcome<'a>, Error> {
547 let reg = &loaded.reg;
548 let value: Ratio = args.value.parse()?;
549 let from = reg.parse(&args.from)?;
550 let to = reg.parse(&args.to)?;
551 let (conv, used) = match (Conversion::between(&from, &to), density) {
554 (Ok(conv), _) => (conv, None),
555 (Err(Error::DimensionMismatch { .. }), Some(d)) => (d.conversion(&from, &to)?, Some(d)),
556 (Err(e), _) => return Err(e),
557 };
558 let exact = conv.apply_exact(value)?;
559 let approx = exact.to_f64()?;
560 Ok(Outcome {
561 args,
562 configs: &loaded.configs,
563 density: used,
564 from,
565 to,
566 conv,
567 exact,
568 approx,
569 })
570}
571
572enum DensityError {
574 Usage(String, Vec<String>),
577 Lib(Error),
579}
580
581fn resolve_density(spec: &str, loaded: &Loaded) -> Result<Constant, DensityError> {
584 let starts_like_a_number = spec.starts_with(|c: char| c.is_ascii_digit() || "+-.".contains(c));
585 if starts_like_a_number {
586 return Constant::density_from_text(&loaded.reg, spec).map_err(DensityError::Lib);
587 }
588 let known: Vec<String> = loaded.densities.iter().map(|d| d.name.clone()).collect();
589 loaded
590 .densities
591 .iter()
592 .find(|d| d.name == spec)
593 .cloned()
594 .ok_or_else(|| {
595 let message = if known.is_empty() {
596 format!(
597 "no density named '{spec}': no loaded config has a [[density]] (try --config water, or write your own: --density 998.2067kg/m3)"
598 )
599 } else {
600 format!("no density named '{spec}'; loaded densities: {}", known.join(", "))
601 };
602 DensityError::Usage(message, known)
603 })
604}
605
606fn render_text(o: &Outcome<'_>) -> String {
607 if o.args.exact {
608 format!("{} {}", o.exact.to_fraction_string(), o.to)
609 } else {
610 format!("{} {}", f64_text(o.approx), o.to)
611 }
612}
613
614fn density_json(b: &Constant) -> String {
616 format!(
617 "{{\"name\":{},\"kind\":{},\"value\":{},\"unit\":{},\"conditions\":{},\"source\":{},\"file\":{},\"line\":{}}}",
618 json_string(&b.name),
619 json_string(b.kind.name()),
620 json_string(&b.value.to_fraction_string()),
621 json_string(b.kind.unit()),
622 json_string(&b.conditions),
623 json_string(&b.source),
624 json_string(&b.origin),
625 b.line
626 )
627}
628
629fn density_note(b: &Constant) -> String {
633 let value = match b.value.to_decimal(15) {
634 Ok((decimal, true)) => decimal,
635 Ok((decimal, false)) => format!("{} (about {decimal})", b.value.to_fraction_string()),
636 Err(_) => b.value.to_fraction_string(),
637 };
638 let clean = |s: &str| -> String {
639 s.chars()
640 .map(|c| if c.is_control() { ' ' } else { c })
641 .collect()
642 };
643 let at = if b.line == 0 {
644 clean(&b.origin)
645 } else {
646 format!("{}:{}", clean(&b.origin), b.line)
647 };
648 format!(
649 "note: used density {} ({} {}; {}; source: {}; {})",
650 clean(&b.name),
651 value,
652 b.kind.unit(),
653 clean(&b.conditions),
654 clean(&b.source),
655 at
656 )
657}
658
659fn render_json(o: &Outcome<'_>) -> Result<String, Error> {
660 let (decimal, complete) = o.exact.to_decimal(15)?;
661 let dims: Vec<String> =
662 o.to.dim()
663 .exponents()
664 .iter()
665 .map(ToString::to_string)
666 .collect();
667 let mut sources: Vec<String> = o.from.sources().to_vec();
668 for s in o.to.sources() {
669 if !sources.contains(s) {
670 sources.push(s.clone());
671 }
672 }
673 if let Some(b) = o.density {
674 sources.push(format!("{} (density {})", b.source, b.name));
675 }
676 let sources: Vec<String> = sources.iter().map(|s| json_string(s)).collect();
677 let exactness = o.density.map_or_else(
678 || o.from.exactness().combine(o.to.exactness()),
679 |b| {
680 o.from
681 .exactness()
682 .combine(o.to.exactness())
683 .combine(b.exactness())
684 },
685 );
686 let packs: Vec<String> = dimvexa::PACKS.iter().map(|p| json_string(p)).collect();
687 Ok(format!(
688 "{{\"schema\":{},\"ok\":true,\
689\"input\":{{\"value\":{},\"unit\":{}}},\
690\"output\":{{\"value\":{},\"unit\":{},\"f64\":{},\"truncated\":{}}},\
691\"exact\":{{\"value\":{},\"factor\":{},\"offset\":{}}},\
692\"exactness\":{},\"dimension\":[{}],\"kind\":{},\"quantity_kind\":{},\
693\"packs\":[{}],\"data_version\":{},\"sources\":[{}],\"config\":{},\"density\":{}}}",
694 json_string(&schema_id("convert")),
695 json_string(&o.args.value),
696 json_string(&o.from.to_string()),
697 json_string(&decimal),
698 json_string(&o.to.to_string()),
699 f64_text(o.approx),
700 !complete,
701 json_string(&o.exact.to_fraction_string()),
702 json_string(&o.conv.factor().to_fraction_string()),
703 json_string(&o.conv.offset().to_fraction_string()),
704 json_string(exactness.name()),
705 dims.join(","),
706 json_string(o.conv.shape().name()),
707 json_string(o.to.kind().name()),
708 packs.join(","),
709 json_string(&dimvexa::data_version()),
710 sources.join(","),
711 configs_json(o.configs),
712 o.density.map_or_else(|| "null".to_string(), density_json),
713 ))
714}
715
716fn group_name(dim: Dim) -> String {
719 let name = match dim.exponents() {
720 [0, 1, 0, 0, 0, 0, 0] => "length",
721 [1, 0, 0, 0, 0, 0, 0] => "mass",
722 [0, 0, 1, 0, 0, 0, 0] => "time",
723 [0, 0, 0, 1, 0, 0, 0] => "electric_current",
724 [0, 0, 0, 0, 1, 0, 0] => "temperature",
725 [0, 0, 0, 0, 0, 1, 0] => "amount_of_substance",
726 [0, 0, 0, 0, 0, 0, 1] => "luminous_intensity",
727 [0, 0, 0, 0, 0, 0, 0] => "angle",
728 [0, -2, 0, 0, 0, 0, 1] => "illuminance",
729 [0, 0, -1, 0, 0, 1, 0] => "catalytic_activity",
730 [0, 2, -2, 0, 0, 0, 0] => "absorbed_dose",
731 [0, 2, 0, 0, 0, 0, 0] => "area",
732 [0, 3, 0, 0, 0, 0, 0] => "volume",
733 [0, 1, -1, 0, 0, 0, 0] => "speed",
734 [0, 1, -2, 0, 0, 0, 0] => "acceleration",
735 [0, 0, -1, 0, 0, 0, 0] => "frequency",
736 [1, 1, -2, 0, 0, 0, 0] => "force",
737 [1, -1, -2, 0, 0, 0, 0] => "pressure",
738 [1, -1, -1, 0, 0, 0, 0] => "dynamic_viscosity",
739 [0, 2, -1, 0, 0, 0, 0] => "kinematic_viscosity",
740 [1, 2, -2, 0, 0, 0, 0] => "energy",
741 [1, 2, -3, 0, 0, 0, 0] => "power",
742 [0, 0, 1, 1, 0, 0, 0] => "electric_charge",
743 [1, 2, -3, -1, 0, 0, 0] => "voltage",
744 [1, 2, -3, -2, 0, 0, 0] => "resistance",
745 [-1, -2, 4, 2, 0, 0, 0] => "capacitance",
746 [-1, -2, 3, 2, 0, 0, 0] => "conductance",
747 [1, 2, -2, -1, 0, 0, 0] => "magnetic_flux",
748 [1, 0, -2, -1, 0, 0, 0] => "magnetic_flux_density",
749 [1, 2, -2, -2, 0, 0, 0] => "inductance",
750 _ => return format!("dimension[{dim}]"),
751 };
752 name.to_string()
753}
754
755fn select_ambiguous(
758 units: &[(String, &Unit)],
759 filtered: bool,
760) -> Vec<&'static dimvexa::AmbiguousDef> {
761 dimvexa::AMBIGUOUS
762 .iter()
763 .filter(|a| {
764 !filtered
765 || a.candidates
766 .iter()
767 .any(|c| units.iter().any(|(_, u)| u.symbol() == *c))
768 })
769 .collect()
770}
771
772fn user_unit<'a>(user: &'a [ConfigUnit], symbol: &str) -> Option<&'a ConfigUnit> {
774 user.iter().find(|u| u.symbol == symbol)
775}
776
777fn origin_json(u: Option<&ConfigUnit>) -> String {
779 u.map_or_else(
780 || "null".to_string(),
781 |u| {
782 format!(
783 "{{\"file\":{},\"line\":{}}}",
784 json_string(&u.origin),
785 u.line
786 )
787 },
788 )
789}
790
791fn cell(s: &str) -> String {
793 let t: String = s
794 .chars()
795 .map(|c| if c.is_control() { ' ' } else { c })
796 .collect();
797 if t.is_empty() { "-".to_string() } else { t }
798}
799
800fn group_counts(reg: &Registry) -> Vec<(String, usize)> {
803 let mut groups: Vec<(String, usize)> = Vec::new();
804 for u in reg.units() {
805 let g = group_name(u.dim());
806 match groups.iter_mut().find(|(name, _)| *name == g) {
807 Some((_, n)) => *n = n.saturating_add(1),
808 None => groups.push((g, 1)),
809 }
810 }
811 groups
812}
813
814fn groups_text(groups: &[(String, usize)]) -> String {
816 let mut s = String::from("# columns: group, units\n");
817 for (g, n) in groups {
818 let _ = writeln!(s, "{g}\t{n}");
819 }
820 s
821}
822
823fn units_text(
824 units: &[(String, &Unit)],
825 ambiguous: &[&dimvexa::AmbiguousDef],
826 mappings: &[Mapping],
827 densities: &[Constant],
828 user: &[ConfigUnit],
829) -> String {
830 let mut s =
831 String::from("# columns: symbol, quantity_kind, exactness, prefixes, aliases, source\n");
832 let mut current: Option<&str> = None;
833 for (g, u) in units {
834 if current != Some(g.as_str()) {
835 let _ = writeln!(s, "\n## {g}");
836 current = Some(g.as_str());
837 }
838 let _ = writeln!(
839 s,
840 "{}\t{}\t{}\t{}\t{}\t{}",
841 cell(u.symbol()),
842 u.kind().name(),
843 u.exactness().name(),
844 u.prefixes().name(),
845 cell(&u.aliases().join(", ")),
846 cell(&user_unit(user, u.symbol()).map_or_else(
847 || u.source().to_string(),
848 |c| format!("{} [yours: {}:{}]", u.source(), c.origin, c.line)
849 ))
850 );
851 }
852 if !ambiguous.is_empty() {
853 s.push_str(
854 "\n## ambiguous symbols (refused unless your config resolves them)\n\
855# columns: symbol, candidates, planned, note\n",
856 );
857 for a in ambiguous {
858 let note = mappings.iter().find(|m| m.name == a.symbol).map_or_else(
859 || a.note.to_string(),
860 |m| {
861 format!(
862 "resolved by config as {} ({}); {}",
863 m.target, m.origin, a.note
864 )
865 },
866 );
867 let _ = writeln!(
868 s,
869 "{}\t{}\t{}\t{}",
870 cell(a.symbol),
871 cell(&a.candidates.join(", ")),
872 cell(&a.planned.join(", ")),
873 cell(¬e)
874 );
875 }
876 }
877 if !mappings.is_empty() {
878 s.push_str(
879 "\n## config mappings (names your --config files resolve)\n# columns: name, unit, file:line\n",
880 );
881 for m in mappings {
882 let _ = writeln!(
883 s,
884 "{}\t{}\t{}:{}",
885 cell(&m.name),
886 cell(&m.target),
887 cell(&m.origin),
888 m.line
889 );
890 }
891 }
892 if !densities.is_empty() {
893 s.push_str(
894 "\n## config densities (constants you supply; use with --density NAME)\n\
895# columns: name, kind, value, unit, conditions, source, file:line\n",
896 );
897 for b in densities {
898 let _ = writeln!(
899 s,
900 "{}\t{}\t{}\t{}\t{}\t{}\t{}:{}",
901 cell(&b.name),
902 b.kind.name(),
903 b.value.to_fraction_string(),
904 b.kind.unit(),
905 cell(&b.conditions),
906 cell(&b.source),
907 cell(&b.origin),
908 b.line
909 );
910 }
911 }
912 s
913}
914
915fn units_json(
916 groups: &[(String, usize)],
917 units: &[(String, &Unit)],
918 ambiguous: &[&dimvexa::AmbiguousDef],
919 group: Option<&str>,
920 loaded: &Loaded,
921) -> String {
922 let items: Vec<String> = units
923 .iter()
924 .map(|(g, u)| {
925 let aliases: Vec<String> = u.aliases().iter().map(|a| json_string(a)).collect();
926 let dims: Vec<String> = u.dim().exponents().iter().map(ToString::to_string).collect();
927 format!(
928 "{{\"symbol\":{},\"group\":{},\"aliases\":[{}],\"dimension\":[{}],\"quantity_kind\":{},\"exactness\":{},\"prefixes\":{},\"namespace\":{},\"source\":{},\"origin\":{}}}",
929 json_string(u.symbol()),
930 json_string(g),
931 aliases.join(","),
932 dims.join(","),
933 json_string(u.kind().name()),
934 json_string(u.exactness().name()),
935 json_string(u.prefixes().name()),
936 json_string(u.namespace()),
937 json_string(u.source()),
938 origin_json(user_unit(&loaded.units, u.symbol())),
939 )
940 })
941 .collect();
942 let strs = |v: &[&str]| {
943 v.iter()
944 .map(|x| json_string(x))
945 .collect::<Vec<_>>()
946 .join(",")
947 };
948 let ambiguous: Vec<String> = ambiguous
949 .iter()
950 .map(|a| {
951 format!(
952 "{{\"symbol\":{},\"candidates\":[{}],\"planned\":[{}],\"note\":{},\"resolved_by\":{}}}",
953 json_string(a.symbol),
954 strs(a.candidates),
955 strs(a.planned),
956 json_string(a.note),
957 loaded
958 .mappings
959 .iter()
960 .find(|m| m.name == a.symbol)
961 .map_or_else(
962 || "null".to_string(),
963 |m| format!(
964 "{{\"unit\":{},\"file\":{},\"line\":{}}}",
965 json_string(&m.target),
966 json_string(&m.origin),
967 m.line
968 )
969 )
970 )
971 })
972 .collect();
973 let group_list: Vec<String> = groups
974 .iter()
975 .map(|(g, n)| format!("{{\"name\":{},\"units\":{n}}}", json_string(g)))
976 .collect();
977 let mappings: Vec<String> = loaded
978 .mappings
979 .iter()
980 .map(|m| {
981 format!(
982 "{{\"name\":{},\"unit\":{},\"file\":{},\"line\":{}}}",
983 json_string(&m.name),
984 json_string(&m.target),
985 json_string(&m.origin),
986 m.line
987 )
988 })
989 .collect();
990 let group = group.map_or_else(
991 || "null".to_string(),
992 |g| json_string(&g.to_ascii_lowercase()),
993 );
994 format!(
995 "{{\"schema\":{},\"ok\":true,\"data_version\":{},\"group\":{},\"groups\":[{}],\"units\":[{}],\"ambiguous\":[{}],\"config\":{},\"mappings\":[{}],\"densities\":[{}]}}",
996 json_string(&schema_id("units")),
997 json_string(&dimvexa::data_version()),
998 group,
999 group_list.join(","),
1000 items.join(","),
1001 ambiguous.join(","),
1002 configs_json(&loaded.configs),
1003 mappings.join(","),
1004 loaded
1005 .densities
1006 .iter()
1007 .map(density_json)
1008 .collect::<Vec<_>>()
1009 .join(",")
1010 )
1011}
1012
1013struct Detail {
1015 queried: String,
1017 symbol: String,
1018 group: String,
1019 aliases: Vec<String>,
1020 dim: Dim,
1021 quantity_kind: &'static str,
1022 exactness: &'static str,
1023 prefixes: Option<&'static str>,
1025 namespace: Option<String>,
1026 factor: String,
1027 offset: String,
1028 sources: Vec<String>,
1029 note: Option<String>,
1030 origin: Option<(String, usize)>,
1032 mapping: Option<(String, usize)>,
1034}
1035
1036fn unit_detail(
1037 reg: &Registry,
1038 user: &[ConfigUnit],
1039 mappings: &[Mapping],
1040 query: &str,
1041) -> Result<Detail, Error> {
1042 if let Some(u) = reg.get(query) {
1043 return Ok(Detail {
1044 queried: query.to_string(),
1045 symbol: u.symbol().to_string(),
1046 group: group_name(u.dim()),
1047 aliases: u.aliases().to_vec(),
1048 dim: u.dim(),
1049 quantity_kind: u.kind().name(),
1050 exactness: u.exactness().name(),
1051 prefixes: Some(u.prefixes().name()),
1052 namespace: Some(u.namespace().to_string()),
1053 factor: u.factor().to_fraction_string(),
1054 offset: u.offset().to_fraction_string(),
1055 sources: vec![u.source().to_string()],
1056 note: Some(u.note().to_string()),
1057 origin: user_unit(user, u.symbol()).map(|c| (c.origin.clone(), c.line)),
1058 mapping: mappings
1059 .iter()
1060 .find(|m| m.name == query)
1061 .map(|m| (m.origin.clone(), m.line)),
1062 });
1063 }
1064 let e = reg.parse(query)?;
1067 Ok(Detail {
1068 queried: query.to_string(),
1069 symbol: query.to_string(),
1070 group: group_name(e.dim()),
1071 aliases: Vec::new(),
1072 dim: e.dim(),
1073 quantity_kind: e.kind().name(),
1074 exactness: e.exactness().name(),
1075 prefixes: None,
1076 namespace: None,
1077 factor: e.factor().to_fraction_string(),
1078 offset: e.offset().to_fraction_string(),
1079 sources: e.sources().to_vec(),
1080 note: None,
1081 origin: None,
1082 mapping: None,
1083 })
1084}
1085
1086fn detail_text(d: &Detail) -> String {
1087 let none = |s: &str| {
1088 if s.is_empty() {
1089 "-".to_string()
1090 } else {
1091 s.to_string()
1092 }
1093 };
1094 let mut s = format!("{}\n", d.symbol);
1095 let mut line = |k: &str, v: String| {
1096 let _ = writeln!(s, " {k:<14}{}", cell(&v));
1097 };
1098 line("group", d.group.clone());
1099 line("aliases", none(&d.aliases.join(", ")));
1100 line("dimension", d.dim.to_string());
1101 line("quantity kind", d.quantity_kind.to_string());
1102 line("exactness", d.exactness.to_string());
1103 if let Some(p) = d.prefixes {
1104 line("prefixes", p.to_string());
1105 }
1106 if let Some(n) = &d.namespace {
1107 line("namespace", n.clone());
1108 }
1109 line("factor to SI", d.factor.clone());
1110 line("offset to SI", d.offset.clone());
1111 for src in &d.sources {
1112 line("source", src.clone());
1113 }
1114 if let Some(n) = d.note.as_deref().filter(|n| !n.is_empty()) {
1115 line("note", n.to_string());
1116 }
1117 if let Some((file, at)) = &d.mapping {
1118 line(
1119 "your name",
1120 format!(
1121 "'{query}' means this unit in your config ({file}:{at})",
1122 query = d.queried
1123 ),
1124 );
1125 }
1126 if let Some((file, at)) = &d.origin {
1127 line(
1128 "defined in",
1129 format!("{file}:{at} (your config, not the shipped table)"),
1130 );
1131 }
1132 s
1133}
1134
1135fn detail_json(d: &Detail) -> String {
1136 let list = |v: &[String]| {
1137 v.iter()
1138 .map(|x| json_string(x))
1139 .collect::<Vec<_>>()
1140 .join(",")
1141 };
1142 let opt = |v: Option<&str>| v.map_or_else(|| "null".to_string(), json_string);
1143 let dims: Vec<String> = d.dim.exponents().iter().map(ToString::to_string).collect();
1144 format!(
1145 "{{\"schema\":{},\"ok\":true,\"data_version\":{},\"unit\":{{\"symbol\":{},\"group\":{},\"aliases\":[{}],\"dimension\":[{}],\"quantity_kind\":{},\"exactness\":{},\"prefixes\":{},\"namespace\":{},\"factor\":{},\"offset\":{},\"sources\":[{}],\"note\":{},\"origin\":{},\"mapping\":{}}}}}",
1146 json_string(&schema_id("units")),
1147 json_string(&dimvexa::data_version()),
1148 json_string(&d.symbol),
1149 json_string(&d.group),
1150 list(&d.aliases),
1151 dims.join(","),
1152 json_string(d.quantity_kind),
1153 json_string(d.exactness),
1154 opt(d.prefixes),
1155 opt(d.namespace.as_deref()),
1156 json_string(&d.factor),
1157 json_string(&d.offset),
1158 list(&d.sources),
1159 opt(d.note.as_deref()),
1160 d.origin.as_ref().map_or_else(
1161 || "null".to_string(),
1162 |(f, l)| format!("{{\"file\":{},\"line\":{l}}}", json_string(f)),
1163 ),
1164 d.mapping.as_ref().map_or_else(
1165 || "null".to_string(),
1166 |(f, l)| {
1167 format!(
1168 "{{\"name\":{},\"file\":{},\"line\":{l}}}",
1169 json_string(&d.queried),
1170 json_string(f)
1171 )
1172 },
1173 ),
1174 )
1175}
1176
1177fn run_units(
1179 target: &UnitsTarget,
1180 config_files: &[String],
1181 json: bool,
1182 out: &mut dyn Write,
1183 err: &mut dyn Write,
1184) -> u8 {
1185 let loaded = match load(config_files) {
1186 Ok(l) => l,
1187 Err(e) => return report_load_error(&e, json, "units", out, err),
1188 };
1189 let mut fail = |code: &str, message: &str, suggestions: &[String], status: u8| -> u8 {
1190 if json {
1191 let _ = writeln!(
1192 out,
1193 "{}",
1194 json_error("units", code, message, &[], suggestions)
1195 );
1196 } else {
1197 let _ = writeln!(err, "error[{code}]: {message}");
1198 }
1199 status
1200 };
1201 let reg = &loaded.reg;
1202 let groups = group_counts(reg);
1203 let (selected, group) = match target {
1204 UnitsTarget::Groups => {
1205 let text = if json {
1206 units_json(&groups, &[], &[], None, &loaded)
1207 } else {
1208 groups_text(&groups)
1209 };
1210 let _ = write!(out, "{text}");
1211 if json {
1212 let _ = writeln!(out);
1213 }
1214 return 0;
1215 }
1216 UnitsTarget::All => (None, None),
1217 UnitsTarget::Query(q) => {
1218 let q_lower = q.to_ascii_lowercase();
1219 if groups.iter().any(|(g, _)| *g == q_lower) {
1220 (Some(q_lower.clone()), Some(q_lower))
1221 } else {
1222 return match unit_detail(reg, &loaded.units, &loaded.mappings, q) {
1223 Ok(d) => {
1224 let text = if json {
1225 detail_json(&d)
1226 } else {
1227 detail_text(&d)
1228 };
1229 let _ = write!(out, "{text}");
1230 if json {
1231 let _ = writeln!(out);
1232 }
1233 0
1234 }
1235 Err(e) => {
1236 let hint = if e.code() == "unknown_unit" {
1237 "; it is not a group either (see 'dimvexa units --groups')".to_string()
1238 } else {
1239 String::new()
1240 };
1241 let mut near = e.candidates().to_vec();
1242 for sug in e.suggestions() {
1243 if !near.contains(sug) {
1244 near.push(sug.clone());
1245 }
1246 }
1247 fail(e.code(), &format!("{e}{hint}"), &near, 1)
1248 }
1249 };
1250 }
1251 }
1252 };
1253 let units: Vec<(String, &Unit)> = reg
1254 .units()
1255 .iter()
1256 .map(|u| (group_name(u.dim()), u))
1257 .filter(|(g, _)| selected.as_ref().is_none_or(|want| g == want))
1258 .collect();
1259 let ambiguous = select_ambiguous(&units, group.is_some());
1260 if json {
1261 let _ = writeln!(
1262 out,
1263 "{}",
1264 units_json(&groups, &units, &ambiguous, group.as_deref(), &loaded)
1265 );
1266 } else {
1267 let _ = write!(
1268 out,
1269 "{}",
1270 units_text(
1271 &units,
1272 &ambiguous,
1273 &loaded.mappings,
1274 &loaded.densities,
1275 &loaded.units,
1276 )
1277 );
1278 }
1279 0
1280}
1281
1282fn configs_list_text() -> String {
1284 let mut s = String::from("# columns: name, names, units, densities, hash\n");
1285 for b in dimvexa_config::BUNDLED {
1286 match b.config() {
1287 Ok(c) => {
1288 let _ = writeln!(
1289 s,
1290 "{}\t{}\t{}\t{}\t{}",
1291 b.name,
1292 c.mappings().len(),
1293 c.units().len(),
1294 c.constants().len(),
1295 c.hash()
1296 );
1297 }
1298 Err(e) => {
1299 let _ = writeln!(s, "{}\terror: {e}", b.name);
1300 }
1301 }
1302 }
1303 s
1304}
1305
1306fn configs_list_json() -> String {
1307 let items: Vec<String> = dimvexa_config::BUNDLED
1308 .iter()
1309 .filter_map(|b| {
1310 let c = b.config().ok()?;
1311 Some(format!(
1312 "{{\"name\":{},\"hash\":{},\"mappings\":{},\"units\":{},\"densities\":{}}}",
1313 json_string(b.name),
1314 json_string(c.hash()),
1315 c.mappings().len(),
1316 c.units().len(),
1317 c.constants().len()
1318 ))
1319 })
1320 .collect();
1321 format!(
1322 "{{\"schema\":{},\"ok\":true,\"configs\":[{}]}}",
1323 json_string(&schema_id("configs")),
1324 items.join(",")
1325 )
1326}
1327
1328fn config_detail_text(c: &Config) -> String {
1330 let mut s = format!("{} (hash {})\n", cell(c.origin()), c.hash());
1331 if !c.mappings().is_empty() {
1332 s.push_str(
1333 "\n## names (what they mean once the config is loaded)\n# columns: name, unit\n",
1334 );
1335 for m in c.mappings() {
1336 let _ = writeln!(s, "{}\t{}", cell(&m.name), cell(&m.target));
1337 }
1338 }
1339 if !c.units().is_empty() {
1340 s.push_str("\n## units\n# columns: symbol, base, factor, exactness, source\n");
1341 for u in c.units() {
1342 let _ = writeln!(
1343 s,
1344 "{}\t{}\t{}\t{}\t{}",
1345 cell(&u.symbol),
1346 cell(&u.base),
1347 u.factor.to_fraction_string(),
1348 u.exactness.name(),
1349 cell(&u.source)
1350 );
1351 }
1352 }
1353 if !c.constants().is_empty() {
1354 s.push_str(
1355 "\n## densities (use with --density NAME)\n# columns: name, value, unit, conditions, source\n",
1356 );
1357 for d in c.constants() {
1358 let _ = writeln!(
1359 s,
1360 "{}\t{}\t{}\t{}\t{}",
1361 cell(&d.name),
1362 d.value.to_fraction_string(),
1363 d.kind.unit(),
1364 cell(&d.conditions),
1365 cell(&d.source)
1366 );
1367 }
1368 }
1369 s
1370}
1371
1372fn config_detail_json(c: &Config) -> String {
1373 let mappings: Vec<String> = c
1374 .mappings()
1375 .iter()
1376 .map(|m| {
1377 format!(
1378 "{{\"name\":{},\"unit\":{},\"line\":{}}}",
1379 json_string(&m.name),
1380 json_string(&m.target),
1381 m.line
1382 )
1383 })
1384 .collect();
1385 let units: Vec<String> = c
1386 .units()
1387 .iter()
1388 .map(|u| {
1389 format!(
1390 "{{\"symbol\":{},\"base\":{},\"factor\":{},\"exactness\":{},\"source\":{},\"line\":{}}}",
1391 json_string(&u.symbol),
1392 json_string(&u.base),
1393 json_string(&u.factor.to_fraction_string()),
1394 json_string(u.exactness.name()),
1395 json_string(&u.source),
1396 u.line
1397 )
1398 })
1399 .collect();
1400 let densities: Vec<String> = c.constants().iter().map(density_json).collect();
1401 format!(
1402 "{{\"schema\":{},\"ok\":true,\"config\":{{\"name\":{},\"hash\":{},\"mappings\":[{}],\"units\":[{}],\"densities\":[{}]}}}}",
1403 json_string(&schema_id("configs")),
1404 json_string(c.origin()),
1405 json_string(c.hash()),
1406 mappings.join(","),
1407 units.join(","),
1408 densities.join(",")
1409 )
1410}
1411
1412fn run_configs(target: Option<&str>, json: bool, out: &mut dyn Write, err: &mut dyn Write) -> u8 {
1414 let text = match target {
1415 None if json => configs_list_json(),
1416 None => configs_list_text(),
1417 Some(spec) => match read_spec(spec) {
1418 Ok(c) if json => config_detail_json(&c),
1419 Ok(c) => config_detail_text(&c),
1420 Err(e) => {
1421 let suggestions: Vec<String> = if matches!(e, LoadError::Io(_)) {
1422 dimvexa_config::BUNDLED
1423 .iter()
1424 .map(|b| b.name.to_string())
1425 .collect()
1426 } else {
1427 Vec::new()
1428 };
1429 let (code, message, status) = match &e {
1430 LoadError::Io(m) => ("usage", m.clone(), 2),
1431 LoadError::Lib(e) => (e.code(), e.to_string(), 1),
1432 };
1433 if json {
1434 let _ = writeln!(
1435 out,
1436 "{}",
1437 json_error("configs", code, &message, &[], &suggestions)
1438 );
1439 } else {
1440 let _ = writeln!(err, "error[{code}]: {message}");
1441 }
1442 return status;
1443 }
1444 },
1445 };
1446 let _ = write!(out, "{text}");
1447 if json {
1448 let _ = writeln!(out);
1449 }
1450 0
1451}
1452
1453pub fn run<I>(args: I, out: &mut dyn Write, err: &mut dyn Write) -> u8
1456where
1457 I: IntoIterator<Item = String>,
1458{
1459 let args: Vec<String> = args.into_iter().collect();
1460 let (parsed, config_files) = match parse_args(&args) {
1461 Ok(p) => p,
1462 Err(u) => {
1463 if u.json {
1464 let _ = writeln!(
1465 out,
1466 "{}",
1467 json_error(u.schema, "usage", &u.message, &[], &u.suggestions)
1468 );
1469 } else {
1470 let _ = writeln!(err, "error[usage]: {}\n\n{USAGE}", u.message);
1471 }
1472 return 2;
1473 }
1474 };
1475 let args = match parsed {
1476 Parsed::Help => {
1477 let _ = write!(out, "{USAGE}");
1478 return 0;
1479 }
1480 Parsed::Version => {
1481 let _ = writeln!(
1482 out,
1483 "{} {} (data {})",
1484 dimvexa::PROJECT_NAME,
1485 env!("CARGO_PKG_VERSION"),
1486 dimvexa::data_version()
1487 );
1488 return 0;
1489 }
1490 Parsed::Units { target, json } => {
1491 return run_units(&target, &config_files, json, out, err);
1492 }
1493 Parsed::Configs { target, json } => {
1494 return run_configs(target.as_deref(), json, out, err);
1495 }
1496 Parsed::Convert(a) => a,
1497 };
1498 let loaded = match load(&config_files) {
1499 Ok(l) => l,
1500 Err(e) => return report_load_error(&e, args.json, "convert", out, err),
1501 };
1502 let density = match &args.density {
1503 None => None,
1504 Some(spec) => match resolve_density(spec, &loaded) {
1505 Ok(d) => Some(d),
1506 Err(e) => {
1507 let (code, message, candidates, suggestions, status) = match &e {
1508 DensityError::Usage(m, known) => {
1509 ("usage", m.clone(), Vec::new(), known.clone(), 2)
1510 }
1511 DensityError::Lib(e) => (
1512 e.code(),
1513 format!("--density: {e}"),
1514 e.candidates().to_vec(),
1515 e.suggestions().to_vec(),
1516 1,
1517 ),
1518 };
1519 if args.json {
1520 let line = json_error("convert", code, &message, &candidates, &suggestions);
1521 let _ = writeln!(out, "{line}");
1522 } else {
1523 let _ = writeln!(err, "error[{code}]: {message}");
1524 }
1525 return status;
1526 }
1527 },
1528 };
1529 match compute(&args, &loaded, density.as_ref()) {
1530 Ok(outcome) => {
1531 let text = if args.json {
1532 match render_json(&outcome) {
1533 Ok(t) => t,
1534 Err(e) => return fail(&args, &e, out, err),
1535 }
1536 } else {
1537 render_text(&outcome)
1538 };
1539 let _ = writeln!(out, "{text}");
1540 if !args.json {
1541 match (outcome.density, &args.density) {
1542 (Some(b), _) => {
1543 let _ = writeln!(err, "{}", density_note(b));
1544 }
1545 (None, Some(spec)) => {
1546 let _ = writeln!(
1547 err,
1548 "note: density {spec} was not needed: both units have the same dimension"
1549 );
1550 }
1551 (None, None) => {}
1552 }
1553 }
1554 0
1555 }
1556 Err(e) => fail(&args, &e, out, err),
1557 }
1558}
1559
1560fn fail(args: &Args, e: &Error, out: &mut dyn Write, err: &mut dyn Write) -> u8 {
1561 if args.json {
1562 let _ = writeln!(
1563 out,
1564 "{}",
1565 json_error(
1566 "convert",
1567 e.code(),
1568 &e.to_string(),
1569 e.candidates(),
1570 e.suggestions()
1571 )
1572 );
1573 } else {
1574 let _ = writeln!(err, "error[{}]: {e}", e.code());
1575 if let Error::DimensionMismatch { from, to } = e {
1576 if Constant::density_needed(*from, *to) && args.density.is_none() {
1577 let _ = writeln!(
1578 err,
1579 "hint: mass and volume need a density. Pass your own number, for example \
1580--density 998.2067kg/m3, or a named one: --config water --density water_25C \
1581(docs/adr/014-one-config-mechanism.md)"
1582 );
1583 }
1584 }
1585 }
1586 1
1587}
1588
1589#[cfg(test)]
1590mod tests {
1591 use super::*;
1592
1593 fn run_str(args: &[&str]) -> (u8, String, String) {
1594 let (mut out, mut err) = (Vec::new(), Vec::new());
1595 let code = run(args.iter().map(ToString::to_string), &mut out, &mut err);
1596 (
1597 code,
1598 String::from_utf8(out).unwrap(),
1599 String::from_utf8(err).unwrap(),
1600 )
1601 }
1602
1603 #[test]
1604 fn escapes_json() {
1605 assert_eq!(json_string("a\"b\\c\n\u{1}é"), "\"a\\\"b\\\\c\\n\\u0001é\"");
1606 }
1607
1608 #[test]
1609 fn f64_text_forms() {
1610 assert_eq!(f64_text(36.0), "36");
1611 assert_eq!(f64_text(0.0), "0");
1612 assert_eq!(f64_text(1e300), "1e300");
1613 assert_eq!(f64_text(2.5e-7), "2.5e-7");
1614 assert_eq!(f64_text(-0.5), "-0.5");
1615 }
1616
1617 #[test]
1618 fn basic_conversion_and_negative_numbers() {
1619 assert_eq!(
1620 run_str(&["convert", "10", "m/s", "km/h"]),
1621 (0, "36 km/h\n".into(), String::new())
1622 );
1623 assert_eq!(run_str(&["-40", "degF", "degC"]).1, "-40 degC\n");
1624 }
1625
1626 #[test]
1627 fn units_lists_the_table() {
1628 let (code, out, _) = run_str(&["units"]);
1629 assert_eq!(code, 0);
1630 assert!(out.lines().any(|l| l == "## pressure"));
1631 assert!(out.lines().any(|l| l.starts_with("psi\t")));
1632 assert!(out.lines().any(|l| l.starts_with("ton\t")));
1633 let (code, out, _) = run_str(&["units", "--groups"]);
1634 assert_eq!(code, 0);
1635 assert!(out.lines().any(|l| l == "volume\t12"));
1636 let (code, out, _) = run_str(&["units", "psi"]);
1637 assert_eq!(code, 0);
1638 assert!(out.contains("group pressure"));
1639 let (code, out, _) = run_str(&["units", "volume"]);
1640 assert_eq!(code, 0);
1641 assert!(out.contains("gal_us\t") && !out.contains("psi\t"));
1642 assert!(out.contains("\ngal\t"), "ambiguous gal belongs to volume");
1643 let (code, out, _) = run_str(&["units", "--json"]);
1644 assert_eq!(code, 0);
1645 assert!(out.contains("\"schema\":\"dimvexa.units/2\""));
1646 assert_eq!(run_str(&["units", "--exact"]).0, 2);
1647 assert_eq!(run_str(&["units", "nope"]).0, 1);
1648 assert_eq!(run_str(&["units", "--groups", "psi"]).0, 2);
1649 assert_eq!(run_str(&["1", "m", "km", "--groups"]).0, 2);
1650 assert_eq!(run_str(&["units", "length", "mass"]).0, 2);
1651 assert_eq!(run_str(&["convert", "units"]).0, 2);
1652 }
1653
1654 #[test]
1655 fn usage_errors_exit_2() {
1656 assert_eq!(run_str(&[]).0, 2);
1657 assert_eq!(run_str(&["convert", "1", "m"]).0, 2);
1658 assert_eq!(run_str(&["1", "m", "km", "--nope"]).0, 2);
1659 let (code, out, _) = run_str(&["1", "m", "--json"]);
1660 assert_eq!(code, 2);
1661 assert!(out.contains("\"code\":\"usage\""));
1662 }
1663}