1#![forbid(unsafe_code)]
21
22use std::fmt::Write as _;
23use std::io::Write;
24
25use dimvexa::{Conversion, Dim, Error, Ratio, Registry, Unit, UnitExpr, registry};
26use dimvexa_config::{Bridge, Config, ConfigUnit, Mapping};
27
28const USAGE: &str = "\
29Usage:
30 dimvexa convert VALUE FROM TO [--exact] [--json] [--config FILE]... [--bridge NAME]
31 dimvexa VALUE FROM TO [--exact] [--json] [--config FILE]... [--bridge NAME]
32 dimvexa units [--groups | GROUP | UNIT] [--json] [--config FILE]...
33
34Converts VALUE from unit FROM to unit TO.
35
36Commands:
37 units list the supported units grouped by quantity, and the ambiguous symbols
38 that are refused
39 units --groups list only the groups (length, mass, volume, ...) with their sizes
40 units GROUP list one group, for example: dimvexa units volume
41 units UNIT show the details of one unit, for example: dimvexa units psi
42
43Options:
44 --exact print the exact rational result (num/den) instead of a decimal
45 --json machine-readable output (schema dimvexa.convert/1; dimvexa.units/1 for units)
46 --config FILE (beta) apply your own names, units and densities, e.g. mt = \"tonne\" (repeatable; see
47 docs/adr/009-user-config.md). Nothing is loaded unless you name the file
48 --bridge NAME use a density from a [[bridge]] in your config for mass <-> volume, e.g.
49 --config water.toml --bridge water_20C; the assumption is printed with the result
50 --version print version and data table version
51 -h, --help print this help
52
53Examples:
54 dimvexa convert 10 m/s km/h
55 dimvexa convert 1 psi Pa --exact
56 dimvexa convert 100 degC degF --json
57 dimvexa convert 1 mt kg --config trade.toml
58 dimvexa convert 1 L kg --config water.toml --bridge water_20C
59 dimvexa convert 1 bbl_oil L --config oil.toml
60
61Precision: the CLI always uses the exact path, so the number printed is the exact result
62rounded once to the nearest f64 (0 ulp). The library's fast f64 path is within 1 ulp (linear
63units) or 2 ulp (temperature scales such as degC and degF) of it; see docs/precision.md.
64
65Exit codes: 0 success, 1 conversion refused or invalid input, 2 usage error.
66";
67
68struct Args {
70 bridge: Option<String>,
72 value: String,
73 from: String,
74 to: String,
75 exact: bool,
76 json: bool,
77}
78
79enum UnitsTarget {
81 All,
83 Groups,
85 Query(String),
87}
88
89enum Parsed {
90 Convert(Args),
91 Units { target: UnitsTarget, json: bool },
92 Help,
93 Version,
94}
95
96struct UsageError {
98 message: String,
99 json: bool,
100 schema: &'static str,
102 suggestions: Vec<String>,
103}
104
105struct ValueOptions {
107 configs: Vec<String>,
108 bridge: Option<String>,
109}
110
111fn split_options(args: &[String]) -> Result<(ValueOptions, Vec<String>), UsageError> {
113 let mut opts = ValueOptions {
114 configs: Vec::new(),
115 bridge: None,
116 };
117 let mut rest = Vec::new();
118 let missing = |what: &str| UsageError {
119 message: format!("'{what}' needs a value"),
120 json: args.iter().any(|x| x == "--json"),
121 schema: if args.iter().any(|x| x == "units") {
122 "units"
123 } else {
124 "convert"
125 },
126 suggestions: Vec::new(),
127 };
128 let mut it = args.iter();
129 while let Some(a) = it.next() {
130 let (name, inline) = match a.split_once('=') {
131 Some((n @ ("--config" | "--bridge"), v)) => (n, Some(v.to_string())),
132 _ => (a.as_str(), None),
133 };
134 match name {
135 "--config" | "--bridge" => {
136 let value = match inline {
137 Some(v) => v,
138 None => it
140 .next()
141 .filter(|v| !v.starts_with("--"))
142 .cloned()
143 .ok_or_else(|| missing(name))?,
144 };
145 if name == "--config" {
146 opts.configs.push(value);
147 } else if opts.bridge.replace(value).is_some() {
148 return Err(UsageError {
149 message: "'--bridge' is given twice; name one bridge".to_string(),
150 ..missing(name)
151 });
152 }
153 }
154 _ => rest.push(a.clone()),
155 }
156 }
157 Ok((opts, rest))
158}
159
160fn parse_args(all: &[String]) -> Result<(Parsed, Vec<String>), UsageError> {
161 let (opts, args) = split_options(all)?;
162 let configs = opts.configs;
163 let args = args.as_slice();
164 let json = args.iter().any(|a| a == "--json");
165 let is_units = args
166 .iter()
167 .find(|a| !a.starts_with('-'))
168 .is_some_and(|a| a == "units");
169 let schema = if is_units { "units" } else { "convert" };
170 let usage = |message: String| UsageError {
171 message,
172 json,
173 schema,
174 suggestions: Vec::new(),
175 };
176 let mut positional: Vec<&str> = Vec::new();
177 let (mut exact, mut want_json, mut want_groups) = (false, false, false);
178 for a in args {
179 match a.as_str() {
180 "--exact" => exact = true,
181 "--json" => want_json = true,
182 "--groups" => want_groups = true,
183 "-h" | "--help" => return Ok((Parsed::Help, configs)),
184 "-V" | "--version" => return Ok((Parsed::Version, configs)),
185 s if s.starts_with("--") => return Err(usage(format!("unknown option '{s}'"))),
186 s => positional.push(s),
187 }
188 }
189 if let Some((&"units", rest)) = positional.split_first() {
190 if exact {
191 return Err(usage("'units' does not take --exact".to_string()));
192 }
193 if opts.bridge.is_some() {
194 return Err(usage(
195 "'--bridge' selects a bridge for a conversion; 'units' lists them".to_string(),
196 ));
197 }
198 let json = want_json;
199 return match (rest, want_groups) {
200 ([], false) => Ok((
201 Parsed::Units {
202 target: UnitsTarget::All,
203 json,
204 },
205 configs,
206 )),
207 ([], true) => Ok((
208 Parsed::Units {
209 target: UnitsTarget::Groups,
210 json,
211 },
212 configs,
213 )),
214 ([_], true) => Err(usage(
215 "'--groups' lists the groups and takes no GROUP or UNIT".to_string(),
216 )),
217 ([query], false) => Ok((
218 Parsed::Units {
219 target: UnitsTarget::Query((*query).to_string()),
220 json,
221 },
222 configs,
223 )),
224 _ => Err(usage(format!(
225 "'units' takes at most one argument (a group such as 'length' or a unit such as 'psi'), got {}",
226 rest.len()
227 ))),
228 };
229 }
230 if want_groups {
231 return Err(usage("'--groups' only applies to 'units'".to_string()));
232 }
233 let bridge = opts.bridge;
234 if positional.first() == Some(&"convert") {
235 positional.remove(0);
236 }
237 match positional.as_slice() {
238 [value, from, to] => Ok((
239 Parsed::Convert(Args {
240 bridge,
241 value: (*value).to_string(),
242 from: (*from).to_string(),
243 to: (*to).to_string(),
244 exact,
245 json: want_json,
246 }),
247 configs,
248 )),
249 other => Err(usage(format!(
250 "expected VALUE FROM TO, got {} argument(s); try 'dimvexa --help'",
251 other.len()
252 ))),
253 }
254}
255
256fn json_string(s: &str) -> String {
258 let mut out = String::with_capacity(s.len().saturating_add(2));
259 out.push('"');
260 for c in s.chars() {
261 match c {
262 '"' => out.push_str("\\\""),
263 '\\' => out.push_str("\\\\"),
264 '\n' => out.push_str("\\n"),
265 '\r' => out.push_str("\\r"),
266 '\t' => out.push_str("\\t"),
267 c if u32::from(c) < 0x20 => {
268 let _ = write!(out, "\\u{:04x}", u32::from(c));
269 }
270 c => out.push(c),
271 }
272 }
273 out.push('"');
274 out
275}
276
277fn f64_text(x: f64) -> String {
280 let a = x.abs();
281 if a != 0.0 && !(1e-4..1e16).contains(&a) {
282 format!("{x:e}")
283 } else {
284 format!("{x}")
285 }
286}
287
288fn schema_id() -> String {
289 format!("{}.convert/1", dimvexa::PROJECT_NAME)
290}
291
292fn json_error(
293 schema: &str,
294 code: &str,
295 message: &str,
296 candidates: &[String],
297 suggestions: &[String],
298) -> String {
299 let mut s = format!(
300 "{{\"schema\":{},\"ok\":false,\"error\":{{\"code\":{},\"message\":{}",
301 json_string(&format!("{}.{schema}/1", dimvexa::PROJECT_NAME)),
302 json_string(code),
303 json_string(message)
304 );
305 if !candidates.is_empty() {
306 let list: Vec<String> = candidates.iter().map(|c| json_string(c)).collect();
307 let _ = write!(s, ",\"candidates\":[{}]", list.join(","));
308 }
309 if !suggestions.is_empty() {
310 let list: Vec<String> = suggestions.iter().map(|c| json_string(c)).collect();
311 let _ = write!(s, ",\"suggestions\":[{}]", list.join(","));
312 }
313 s.push_str("}}");
314 s
315}
316
317struct Loaded {
319 reg: Registry,
320 mappings: Vec<Mapping>,
322 units: Vec<ConfigUnit>,
324 bridges: Vec<Bridge>,
326 configs: Vec<(String, String)>,
328}
329
330enum LoadError {
332 Io(String),
334 Lib(Error),
336}
337
338const MAX_CONFIG_BYTES: u64 = 1 << 20;
341
342fn read_config(file: &str) -> Result<String, LoadError> {
344 use std::io::Read as _;
345 let fail = |why: String| LoadError::Io(format!("cannot read config '{file}': {why}"));
346 let meta = std::fs::metadata(file).map_err(|e| fail(e.to_string()))?;
347 if !meta.is_file() {
348 return Err(fail("not a regular file".to_string()));
349 }
350 let mut text = String::new();
351 std::fs::File::open(file)
352 .map_err(|e| fail(e.to_string()))?
353 .take(MAX_CONFIG_BYTES.saturating_add(1))
354 .read_to_string(&mut text)
355 .map_err(|e| fail(e.to_string()))?;
356 if u64::try_from(text.len()).map_or(true, |n| n > MAX_CONFIG_BYTES) {
357 return Err(fail(format!("larger than {MAX_CONFIG_BYTES} bytes")));
358 }
359 Ok(text)
360}
361
362fn load(config_files: &[String]) -> Result<Loaded, LoadError> {
363 let mut reg = registry().map_err(LoadError::Lib)?;
364 let (mut parsed, mut configs) = (Vec::new(), Vec::new());
365 for file in config_files {
366 let text = read_config(file)?;
367 let cfg = Config::parse(&text, file).map_err(LoadError::Lib)?;
368 configs.push((file.clone(), cfg.hash().to_string()));
369 parsed.push(cfg);
370 }
371 let applied = dimvexa_config::apply(&mut reg, &parsed).map_err(LoadError::Lib)?;
372 let bridges = dimvexa_config::bridges(&parsed);
373 Ok(Loaded {
374 reg,
375 mappings: applied.mappings,
376 units: applied.units,
377 bridges,
378 configs,
379 })
380}
381
382fn report_load_error(
384 e: &LoadError,
385 json: bool,
386 schema: &str,
387 out: &mut dyn Write,
388 err: &mut dyn Write,
389) -> u8 {
390 let (code, message, status) = match e {
391 LoadError::Io(m) => ("usage", m.clone(), 2),
392 LoadError::Lib(e) => (e.code(), e.to_string(), 1),
393 };
394 if json {
395 let _ = writeln!(out, "{}", json_error(schema, code, &message, &[], &[]));
396 } else {
397 let _ = writeln!(err, "error[{code}]: {message}");
398 }
399 status
400}
401
402fn configs_json(configs: &[(String, String)]) -> String {
404 let items: Vec<String> = configs
405 .iter()
406 .map(|(f, h)| {
407 format!(
408 "{{\"file\":{},\"hash\":{}}}",
409 json_string(f),
410 json_string(h)
411 )
412 })
413 .collect();
414 format!("[{}]", items.join(","))
415}
416
417struct Outcome<'a> {
419 args: &'a Args,
420 configs: &'a [(String, String)],
421 bridge: Option<&'a Bridge>,
423 from: UnitExpr,
424 to: UnitExpr,
425 conv: Conversion,
426 exact: Ratio,
427 approx: f64,
429}
430
431fn compute<'a>(args: &'a Args, loaded: &'a Loaded) -> Result<Outcome<'a>, Error> {
432 let reg = &loaded.reg;
433 let value: Ratio = args.value.parse()?;
434 let from = reg.parse(&args.from)?;
435 let to = reg.parse(&args.to)?;
436 let (conv, bridge) = match (Conversion::between(&from, &to), &args.bridge) {
439 (Ok(conv), _) => (conv, None),
440 (Err(Error::DimensionMismatch { .. }), Some(name)) => {
441 let bridge = loaded
442 .bridges
443 .iter()
444 .find(|b| b.name == *name)
445 .ok_or_else(|| Error::Refused {
446 reason: format!("no bridge named '{name}' is loaded"),
447 suggestions: loaded.bridges.iter().map(|b| b.name.clone()).collect(),
448 })?;
449 (bridge.conversion(&from, &to)?, Some(bridge))
450 }
451 (Err(e), _) => return Err(e),
452 };
453 let exact = conv.apply_exact(value)?;
454 let approx = exact.to_f64()?;
455 Ok(Outcome {
456 args,
457 configs: &loaded.configs,
458 bridge,
459 from,
460 to,
461 conv,
462 exact,
463 approx,
464 })
465}
466
467fn render_text(o: &Outcome<'_>) -> String {
468 if o.args.exact {
469 format!("{} {}", o.exact.to_fraction_string(), o.to)
470 } else {
471 format!("{} {}", f64_text(o.approx), o.to)
472 }
473}
474
475fn bridge_json(b: &Bridge) -> String {
477 format!(
478 "{{\"name\":{},\"kind\":{},\"value\":{},\"unit\":{},\"conditions\":{},\"source\":{},\"file\":{},\"line\":{}}}",
479 json_string(&b.name),
480 json_string(b.kind.name()),
481 json_string(&b.value.to_fraction_string()),
482 json_string(b.kind.unit()),
483 json_string(&b.conditions),
484 json_string(&b.source),
485 json_string(&b.origin),
486 b.line
487 )
488}
489
490fn bridge_note(b: &Bridge) -> String {
494 let value = match b.value.to_decimal(15) {
495 Ok((decimal, true)) => decimal,
496 Ok((decimal, false)) => format!("{} (about {decimal})", b.value.to_fraction_string()),
497 Err(_) => b.value.to_fraction_string(),
498 };
499 let clean = |s: &str| -> String {
500 s.chars()
501 .map(|c| if c.is_control() { ' ' } else { c })
502 .collect()
503 };
504 format!(
505 "note: used bridge {} ({} {} {}; {}; source: {}; {}:{})",
506 clean(&b.name),
507 b.kind.name(),
508 value,
509 b.kind.unit(),
510 clean(&b.conditions),
511 clean(&b.source),
512 clean(&b.origin),
513 b.line
514 )
515}
516
517fn render_json(o: &Outcome<'_>) -> Result<String, Error> {
518 let (decimal, complete) = o.exact.to_decimal(15)?;
519 let dims: Vec<String> =
520 o.to.dim()
521 .exponents()
522 .iter()
523 .map(ToString::to_string)
524 .collect();
525 let mut sources: Vec<String> = o.from.sources().to_vec();
526 for s in o.to.sources() {
527 if !sources.contains(s) {
528 sources.push(s.clone());
529 }
530 }
531 if let Some(b) = o.bridge {
532 sources.push(format!("{} (bridge {})", b.source, b.name));
533 }
534 let sources: Vec<String> = sources.iter().map(|s| json_string(s)).collect();
535 let exactness = o.bridge.map_or_else(
536 || o.from.exactness().combine(o.to.exactness()),
537 |b| {
538 o.from
539 .exactness()
540 .combine(o.to.exactness())
541 .combine(b.exactness())
542 },
543 );
544 let packs: Vec<String> = dimvexa::PACKS.iter().map(|p| json_string(p)).collect();
545 Ok(format!(
546 "{{\"schema\":{},\"ok\":true,\
547\"input\":{{\"value\":{},\"unit\":{}}},\
548\"output\":{{\"value\":{},\"unit\":{},\"f64\":{},\"truncated\":{}}},\
549\"exact\":{{\"value\":{},\"factor\":{},\"offset\":{}}},\
550\"exactness\":{},\"dimension\":[{}],\"kind\":{},\"quantity_kind\":{},\
551\"profile\":null,\"packs\":[{}],\"data_version\":{},\"sources\":[{}],\"config\":{},\"bridge\":{}}}",
552 json_string(&schema_id()),
553 json_string(&o.args.value),
554 json_string(&o.from.to_string()),
555 json_string(&decimal),
556 json_string(&o.to.to_string()),
557 f64_text(o.approx),
558 !complete,
559 json_string(&o.exact.to_fraction_string()),
560 json_string(&o.conv.factor().to_fraction_string()),
561 json_string(&o.conv.offset().to_fraction_string()),
562 json_string(exactness.name()),
563 dims.join(","),
564 json_string(o.conv.shape().name()),
565 json_string(o.to.kind().name()),
566 packs.join(","),
567 json_string(&dimvexa::data_version()),
568 sources.join(","),
569 configs_json(o.configs),
570 o.bridge.map_or_else(|| "null".to_string(), bridge_json),
571 ))
572}
573
574fn group_name(dim: Dim) -> String {
577 let name = match dim.exponents() {
578 [0, 1, 0, 0, 0, 0, 0] => "length",
579 [1, 0, 0, 0, 0, 0, 0] => "mass",
580 [0, 0, 1, 0, 0, 0, 0] => "time",
581 [0, 0, 0, 1, 0, 0, 0] => "electric_current",
582 [0, 0, 0, 0, 1, 0, 0] => "temperature",
583 [0, 0, 0, 0, 0, 1, 0] => "amount_of_substance",
584 [0, 0, 0, 0, 0, 0, 1] => "luminous_intensity",
585 [0, 0, 0, 0, 0, 0, 0] => "angle",
586 [0, -2, 0, 0, 0, 0, 1] => "illuminance",
587 [0, 0, -1, 0, 0, 1, 0] => "catalytic_activity",
588 [0, 2, -2, 0, 0, 0, 0] => "absorbed_dose",
589 [0, 2, 0, 0, 0, 0, 0] => "area",
590 [0, 3, 0, 0, 0, 0, 0] => "volume",
591 [0, 1, -1, 0, 0, 0, 0] => "speed",
592 [0, 1, -2, 0, 0, 0, 0] => "acceleration",
593 [0, 0, -1, 0, 0, 0, 0] => "frequency",
594 [1, 1, -2, 0, 0, 0, 0] => "force",
595 [1, -1, -2, 0, 0, 0, 0] => "pressure",
596 [1, 2, -2, 0, 0, 0, 0] => "energy",
597 [1, 2, -3, 0, 0, 0, 0] => "power",
598 [0, 0, 1, 1, 0, 0, 0] => "electric_charge",
599 [1, 2, -3, -1, 0, 0, 0] => "voltage",
600 [1, 2, -3, -2, 0, 0, 0] => "resistance",
601 [-1, -2, 4, 2, 0, 0, 0] => "capacitance",
602 [-1, -2, 3, 2, 0, 0, 0] => "conductance",
603 [1, 2, -2, -1, 0, 0, 0] => "magnetic_flux",
604 [1, 0, -2, -1, 0, 0, 0] => "magnetic_flux_density",
605 [1, 2, -2, -2, 0, 0, 0] => "inductance",
606 _ => return format!("dimension[{dim}]"),
607 };
608 name.to_string()
609}
610
611fn select_ambiguous(
614 units: &[(String, &Unit)],
615 filtered: bool,
616) -> Vec<&'static dimvexa::AmbiguousDef> {
617 dimvexa::AMBIGUOUS
618 .iter()
619 .filter(|a| {
620 !filtered
621 || a.candidates
622 .iter()
623 .any(|c| units.iter().any(|(_, u)| u.symbol() == *c))
624 })
625 .collect()
626}
627
628fn user_unit<'a>(user: &'a [ConfigUnit], symbol: &str) -> Option<&'a ConfigUnit> {
630 user.iter().find(|u| u.symbol == symbol)
631}
632
633fn origin_json(u: Option<&ConfigUnit>) -> String {
635 u.map_or_else(
636 || "null".to_string(),
637 |u| {
638 format!(
639 "{{\"file\":{},\"line\":{}}}",
640 json_string(&u.origin),
641 u.line
642 )
643 },
644 )
645}
646
647fn cell(s: &str) -> String {
649 let t: String = s
650 .chars()
651 .map(|c| if c.is_control() { ' ' } else { c })
652 .collect();
653 if t.is_empty() { "-".to_string() } else { t }
654}
655
656fn group_counts(reg: &Registry) -> Vec<(String, usize)> {
659 let mut groups: Vec<(String, usize)> = Vec::new();
660 for u in reg.units() {
661 let g = group_name(u.dim());
662 match groups.iter_mut().find(|(name, _)| *name == g) {
663 Some((_, n)) => *n = n.saturating_add(1),
664 None => groups.push((g, 1)),
665 }
666 }
667 groups
668}
669
670fn groups_text(groups: &[(String, usize)]) -> String {
672 let mut s = String::from("# columns: group, units\n");
673 for (g, n) in groups {
674 let _ = writeln!(s, "{g}\t{n}");
675 }
676 s
677}
678
679fn units_text(
680 units: &[(String, &Unit)],
681 ambiguous: &[&dimvexa::AmbiguousDef],
682 mappings: &[Mapping],
683 bridges: &[Bridge],
684 user: &[ConfigUnit],
685) -> String {
686 let mut s =
687 String::from("# columns: symbol, quantity_kind, exactness, prefixes, aliases, source\n");
688 let mut current: Option<&str> = None;
689 for (g, u) in units {
690 if current != Some(g.as_str()) {
691 let _ = writeln!(s, "\n## {g}");
692 current = Some(g.as_str());
693 }
694 let _ = writeln!(
695 s,
696 "{}\t{}\t{}\t{}\t{}\t{}",
697 cell(u.symbol()),
698 u.kind().name(),
699 u.exactness().name(),
700 u.prefixes().name(),
701 cell(&u.aliases().join(", ")),
702 cell(&user_unit(user, u.symbol()).map_or_else(
703 || u.source().to_string(),
704 |c| format!("{} [yours: {}:{}]", u.source(), c.origin, c.line)
705 ))
706 );
707 }
708 if !ambiguous.is_empty() {
709 s.push_str(
710 "\n## ambiguous symbols (refused unless your config resolves them)\n\
711# columns: symbol, candidates, planned, note\n",
712 );
713 for a in ambiguous {
714 let note = mappings.iter().find(|m| m.name == a.symbol).map_or_else(
715 || a.note.to_string(),
716 |m| {
717 format!(
718 "resolved by config as {} ({}); {}",
719 m.target, m.origin, a.note
720 )
721 },
722 );
723 let _ = writeln!(
724 s,
725 "{}\t{}\t{}\t{}",
726 cell(a.symbol),
727 cell(&a.candidates.join(", ")),
728 cell(&a.planned.join(", ")),
729 cell(¬e)
730 );
731 }
732 }
733 if !mappings.is_empty() {
734 s.push_str(
735 "\n## config mappings (names your --config files resolve)\n# columns: name, unit, file:line\n",
736 );
737 for m in mappings {
738 let _ = writeln!(
739 s,
740 "{}\t{}\t{}:{}",
741 cell(&m.name),
742 cell(&m.target),
743 cell(&m.origin),
744 m.line
745 );
746 }
747 }
748 if !bridges.is_empty() {
749 s.push_str(
750 "\n## config bridges (constants you supply; use with --bridge NAME)\n\
751# columns: name, kind, value, unit, conditions, source, file:line\n",
752 );
753 for b in bridges {
754 let _ = writeln!(
755 s,
756 "{}\t{}\t{}\t{}\t{}\t{}\t{}:{}",
757 cell(&b.name),
758 b.kind.name(),
759 b.value.to_fraction_string(),
760 b.kind.unit(),
761 cell(&b.conditions),
762 cell(&b.source),
763 cell(&b.origin),
764 b.line
765 );
766 }
767 }
768 s
769}
770
771fn units_json(
772 groups: &[(String, usize)],
773 units: &[(String, &Unit)],
774 ambiguous: &[&dimvexa::AmbiguousDef],
775 group: Option<&str>,
776 loaded: &Loaded,
777) -> String {
778 let items: Vec<String> = units
779 .iter()
780 .map(|(g, u)| {
781 let aliases: Vec<String> = u.aliases().iter().map(|a| json_string(a)).collect();
782 let dims: Vec<String> = u.dim().exponents().iter().map(ToString::to_string).collect();
783 format!(
784 "{{\"symbol\":{},\"group\":{},\"aliases\":[{}],\"dimension\":[{}],\"quantity_kind\":{},\"exactness\":{},\"prefixes\":{},\"namespace\":{},\"source\":{},\"origin\":{}}}",
785 json_string(u.symbol()),
786 json_string(g),
787 aliases.join(","),
788 dims.join(","),
789 json_string(u.kind().name()),
790 json_string(u.exactness().name()),
791 json_string(u.prefixes().name()),
792 json_string(u.namespace()),
793 json_string(u.source()),
794 origin_json(user_unit(&loaded.units, u.symbol())),
795 )
796 })
797 .collect();
798 let strs = |v: &[&str]| {
799 v.iter()
800 .map(|x| json_string(x))
801 .collect::<Vec<_>>()
802 .join(",")
803 };
804 let ambiguous: Vec<String> = ambiguous
805 .iter()
806 .map(|a| {
807 format!(
808 "{{\"symbol\":{},\"candidates\":[{}],\"planned\":[{}],\"note\":{},\"resolved_by\":{}}}",
809 json_string(a.symbol),
810 strs(a.candidates),
811 strs(a.planned),
812 json_string(a.note),
813 loaded
814 .mappings
815 .iter()
816 .find(|m| m.name == a.symbol)
817 .map_or_else(
818 || "null".to_string(),
819 |m| format!(
820 "{{\"unit\":{},\"file\":{},\"line\":{}}}",
821 json_string(&m.target),
822 json_string(&m.origin),
823 m.line
824 )
825 )
826 )
827 })
828 .collect();
829 let group_list: Vec<String> = groups
830 .iter()
831 .map(|(g, n)| format!("{{\"name\":{},\"units\":{n}}}", json_string(g)))
832 .collect();
833 let mappings: Vec<String> = loaded
834 .mappings
835 .iter()
836 .map(|m| {
837 format!(
838 "{{\"name\":{},\"unit\":{},\"file\":{},\"line\":{}}}",
839 json_string(&m.name),
840 json_string(&m.target),
841 json_string(&m.origin),
842 m.line
843 )
844 })
845 .collect();
846 let group = group.map_or_else(
847 || "null".to_string(),
848 |g| json_string(&g.to_ascii_lowercase()),
849 );
850 format!(
851 "{{\"schema\":{},\"ok\":true,\"data_version\":{},\"group\":{},\"groups\":[{}],\"units\":[{}],\"ambiguous\":[{}],\"config\":{},\"mappings\":[{}],\"bridges\":[{}]}}",
852 json_string(&format!("{}.units/1", dimvexa::PROJECT_NAME)),
853 json_string(&dimvexa::data_version()),
854 group,
855 group_list.join(","),
856 items.join(","),
857 ambiguous.join(","),
858 configs_json(&loaded.configs),
859 mappings.join(","),
860 loaded
861 .bridges
862 .iter()
863 .map(bridge_json)
864 .collect::<Vec<_>>()
865 .join(",")
866 )
867}
868
869struct Detail {
871 queried: String,
873 symbol: String,
874 group: String,
875 aliases: Vec<String>,
876 dim: Dim,
877 quantity_kind: &'static str,
878 exactness: &'static str,
879 prefixes: Option<&'static str>,
881 namespace: Option<String>,
882 factor: String,
883 offset: String,
884 sources: Vec<String>,
885 note: Option<String>,
886 origin: Option<(String, usize)>,
888 mapping: Option<(String, usize)>,
890}
891
892fn unit_detail(
893 reg: &Registry,
894 user: &[ConfigUnit],
895 mappings: &[Mapping],
896 query: &str,
897) -> Result<Detail, Error> {
898 if let Some(u) = reg.get(query) {
899 return Ok(Detail {
900 queried: query.to_string(),
901 symbol: u.symbol().to_string(),
902 group: group_name(u.dim()),
903 aliases: u.aliases().to_vec(),
904 dim: u.dim(),
905 quantity_kind: u.kind().name(),
906 exactness: u.exactness().name(),
907 prefixes: Some(u.prefixes().name()),
908 namespace: Some(u.namespace().to_string()),
909 factor: u.factor().to_fraction_string(),
910 offset: u.offset().to_fraction_string(),
911 sources: vec![u.source().to_string()],
912 note: Some(u.note().to_string()),
913 origin: user_unit(user, u.symbol()).map(|c| (c.origin.clone(), c.line)),
914 mapping: mappings
915 .iter()
916 .find(|m| m.name == query)
917 .map(|m| (m.origin.clone(), m.line)),
918 });
919 }
920 let e = reg.parse(query)?;
923 Ok(Detail {
924 queried: query.to_string(),
925 symbol: query.to_string(),
926 group: group_name(e.dim()),
927 aliases: Vec::new(),
928 dim: e.dim(),
929 quantity_kind: e.kind().name(),
930 exactness: e.exactness().name(),
931 prefixes: None,
932 namespace: None,
933 factor: e.factor().to_fraction_string(),
934 offset: e.offset().to_fraction_string(),
935 sources: e.sources().to_vec(),
936 note: None,
937 origin: None,
938 mapping: None,
939 })
940}
941
942fn detail_text(d: &Detail) -> String {
943 let none = |s: &str| {
944 if s.is_empty() {
945 "-".to_string()
946 } else {
947 s.to_string()
948 }
949 };
950 let mut s = format!("{}\n", d.symbol);
951 let mut line = |k: &str, v: String| {
952 let _ = writeln!(s, " {k:<14}{}", cell(&v));
953 };
954 line("group", d.group.clone());
955 line("aliases", none(&d.aliases.join(", ")));
956 line("dimension", d.dim.to_string());
957 line("quantity kind", d.quantity_kind.to_string());
958 line("exactness", d.exactness.to_string());
959 if let Some(p) = d.prefixes {
960 line("prefixes", p.to_string());
961 }
962 if let Some(n) = &d.namespace {
963 line("namespace", n.clone());
964 }
965 line("factor to SI", d.factor.clone());
966 line("offset to SI", d.offset.clone());
967 for src in &d.sources {
968 line("source", src.clone());
969 }
970 if let Some(n) = d.note.as_deref().filter(|n| !n.is_empty()) {
971 line("note", n.to_string());
972 }
973 if let Some((file, at)) = &d.mapping {
974 line(
975 "your name",
976 format!(
977 "'{query}' means this unit in your config ({file}:{at})",
978 query = d.queried
979 ),
980 );
981 }
982 if let Some((file, at)) = &d.origin {
983 line(
984 "defined in",
985 format!("{file}:{at} (your config, not the shipped table)"),
986 );
987 }
988 s
989}
990
991fn detail_json(d: &Detail) -> String {
992 let list = |v: &[String]| {
993 v.iter()
994 .map(|x| json_string(x))
995 .collect::<Vec<_>>()
996 .join(",")
997 };
998 let opt = |v: Option<&str>| v.map_or_else(|| "null".to_string(), json_string);
999 let dims: Vec<String> = d.dim.exponents().iter().map(ToString::to_string).collect();
1000 format!(
1001 "{{\"schema\":{},\"ok\":true,\"data_version\":{},\"unit\":{{\"symbol\":{},\"group\":{},\"aliases\":[{}],\"dimension\":[{}],\"quantity_kind\":{},\"exactness\":{},\"prefixes\":{},\"namespace\":{},\"factor\":{},\"offset\":{},\"sources\":[{}],\"note\":{},\"origin\":{},\"mapping\":{}}}}}",
1002 json_string(&format!("{}.units/1", dimvexa::PROJECT_NAME)),
1003 json_string(&dimvexa::data_version()),
1004 json_string(&d.symbol),
1005 json_string(&d.group),
1006 list(&d.aliases),
1007 dims.join(","),
1008 json_string(d.quantity_kind),
1009 json_string(d.exactness),
1010 opt(d.prefixes),
1011 opt(d.namespace.as_deref()),
1012 json_string(&d.factor),
1013 json_string(&d.offset),
1014 list(&d.sources),
1015 opt(d.note.as_deref()),
1016 d.origin.as_ref().map_or_else(
1017 || "null".to_string(),
1018 |(f, l)| format!("{{\"file\":{},\"line\":{l}}}", json_string(f)),
1019 ),
1020 d.mapping.as_ref().map_or_else(
1021 || "null".to_string(),
1022 |(f, l)| {
1023 format!(
1024 "{{\"name\":{},\"file\":{},\"line\":{l}}}",
1025 json_string(&d.queried),
1026 json_string(f)
1027 )
1028 },
1029 ),
1030 )
1031}
1032
1033fn run_units(
1035 target: &UnitsTarget,
1036 config_files: &[String],
1037 json: bool,
1038 out: &mut dyn Write,
1039 err: &mut dyn Write,
1040) -> u8 {
1041 let loaded = match load(config_files) {
1042 Ok(l) => l,
1043 Err(e) => return report_load_error(&e, json, "units", out, err),
1044 };
1045 let mut fail = |code: &str, message: &str, suggestions: &[String], status: u8| -> u8 {
1046 if json {
1047 let _ = writeln!(
1048 out,
1049 "{}",
1050 json_error("units", code, message, &[], suggestions)
1051 );
1052 } else {
1053 let _ = writeln!(err, "error[{code}]: {message}");
1054 }
1055 status
1056 };
1057 let reg = &loaded.reg;
1058 let groups = group_counts(reg);
1059 let (selected, group) = match target {
1060 UnitsTarget::Groups => {
1061 let text = if json {
1062 units_json(&groups, &[], &[], None, &loaded)
1063 } else {
1064 groups_text(&groups)
1065 };
1066 let _ = write!(out, "{text}");
1067 if json {
1068 let _ = writeln!(out);
1069 }
1070 return 0;
1071 }
1072 UnitsTarget::All => (None, None),
1073 UnitsTarget::Query(q) => {
1074 let q_lower = q.to_ascii_lowercase();
1075 if groups.iter().any(|(g, _)| *g == q_lower) {
1076 (Some(q_lower.clone()), Some(q_lower))
1077 } else {
1078 return match unit_detail(reg, &loaded.units, &loaded.mappings, q) {
1079 Ok(d) => {
1080 let text = if json {
1081 detail_json(&d)
1082 } else {
1083 detail_text(&d)
1084 };
1085 let _ = write!(out, "{text}");
1086 if json {
1087 let _ = writeln!(out);
1088 }
1089 0
1090 }
1091 Err(e) => {
1092 let hint = if e.code() == "unknown_unit" {
1093 "; it is not a group either (see 'dimvexa units --groups')".to_string()
1094 } else {
1095 String::new()
1096 };
1097 let mut near = e.candidates().to_vec();
1098 for sug in e.suggestions() {
1099 if !near.contains(sug) {
1100 near.push(sug.clone());
1101 }
1102 }
1103 fail(e.code(), &format!("{e}{hint}"), &near, 1)
1104 }
1105 };
1106 }
1107 }
1108 };
1109 let units: Vec<(String, &Unit)> = reg
1110 .units()
1111 .iter()
1112 .map(|u| (group_name(u.dim()), u))
1113 .filter(|(g, _)| selected.as_ref().is_none_or(|want| g == want))
1114 .collect();
1115 let ambiguous = select_ambiguous(&units, group.is_some());
1116 if json {
1117 let _ = writeln!(
1118 out,
1119 "{}",
1120 units_json(&groups, &units, &ambiguous, group.as_deref(), &loaded)
1121 );
1122 } else {
1123 let _ = write!(
1124 out,
1125 "{}",
1126 units_text(
1127 &units,
1128 &ambiguous,
1129 &loaded.mappings,
1130 &loaded.bridges,
1131 &loaded.units,
1132 )
1133 );
1134 }
1135 0
1136}
1137
1138pub fn run<I>(args: I, out: &mut dyn Write, err: &mut dyn Write) -> u8
1141where
1142 I: IntoIterator<Item = String>,
1143{
1144 let args: Vec<String> = args.into_iter().collect();
1145 let (parsed, config_files) = match parse_args(&args) {
1146 Ok(p) => p,
1147 Err(u) => {
1148 if u.json {
1149 let _ = writeln!(
1150 out,
1151 "{}",
1152 json_error(u.schema, "usage", &u.message, &[], &u.suggestions)
1153 );
1154 } else {
1155 let _ = writeln!(err, "error[usage]: {}\n\n{USAGE}", u.message);
1156 }
1157 return 2;
1158 }
1159 };
1160 let args = match parsed {
1161 Parsed::Help => {
1162 let _ = write!(out, "{USAGE}");
1163 return 0;
1164 }
1165 Parsed::Version => {
1166 let _ = writeln!(
1167 out,
1168 "{} {} (data {})",
1169 dimvexa::PROJECT_NAME,
1170 env!("CARGO_PKG_VERSION"),
1171 dimvexa::data_version()
1172 );
1173 return 0;
1174 }
1175 Parsed::Units { target, json } => {
1176 return run_units(&target, &config_files, json, out, err);
1177 }
1178 Parsed::Convert(a) => a,
1179 };
1180 let loaded = match load(&config_files) {
1181 Ok(l) => l,
1182 Err(e) => return report_load_error(&e, args.json, "convert", out, err),
1183 };
1184 if let Some(name) = &args.bridge {
1185 if !loaded.bridges.iter().any(|b| b.name == *name) {
1186 let known: Vec<String> = loaded.bridges.iter().map(|b| b.name.clone()).collect();
1187 let message = if known.is_empty() {
1188 format!(
1189 "no bridge named '{name}': no config with a [[bridge]] is loaded (use --config FILE)"
1190 )
1191 } else {
1192 format!(
1193 "no bridge named '{name}'; loaded bridges: {}",
1194 known.join(", ")
1195 )
1196 };
1197 if args.json {
1198 let _ = writeln!(
1199 out,
1200 "{}",
1201 json_error("convert", "usage", &message, &[], &known)
1202 );
1203 } else {
1204 let _ = writeln!(err, "error[usage]: {message}");
1205 }
1206 return 2;
1207 }
1208 }
1209 match compute(&args, &loaded) {
1210 Ok(outcome) => {
1211 let text = if args.json {
1212 match render_json(&outcome) {
1213 Ok(t) => t,
1214 Err(e) => return fail(&args, &e, out, err),
1215 }
1216 } else {
1217 render_text(&outcome)
1218 };
1219 let _ = writeln!(out, "{text}");
1220 if !args.json {
1221 match (outcome.bridge, &args.bridge) {
1222 (Some(b), _) => {
1223 let _ = writeln!(err, "{}", bridge_note(b));
1224 }
1225 (None, Some(name)) => {
1226 let _ = writeln!(
1227 err,
1228 "note: bridge {name} was not needed: both units have the same dimension"
1229 );
1230 }
1231 (None, None) => {}
1232 }
1233 }
1234 0
1235 }
1236 Err(e) => fail(&args, &e, out, err),
1237 }
1238}
1239
1240fn fail(args: &Args, e: &Error, out: &mut dyn Write, err: &mut dyn Write) -> u8 {
1241 if args.json {
1242 let _ = writeln!(
1243 out,
1244 "{}",
1245 json_error(
1246 "convert",
1247 e.code(),
1248 &e.to_string(),
1249 e.candidates(),
1250 e.suggestions()
1251 )
1252 );
1253 } else {
1254 let _ = writeln!(err, "error[{}]: {e}", e.code());
1255 if let Error::DimensionMismatch { from, to } = e {
1256 if Bridge::density_needed(*from, *to) && args.bridge.is_none() {
1257 let _ = writeln!(
1258 err,
1259 "hint: mass and volume need a density. Define a [[bridge]] in a config file and pass \
1260--config FILE --bridge NAME (docs/adr/009-user-config.md)"
1261 );
1262 }
1263 }
1264 }
1265 1
1266}
1267
1268#[cfg(test)]
1269mod tests {
1270 use super::*;
1271
1272 fn run_str(args: &[&str]) -> (u8, String, String) {
1273 let (mut out, mut err) = (Vec::new(), Vec::new());
1274 let code = run(args.iter().map(ToString::to_string), &mut out, &mut err);
1275 (
1276 code,
1277 String::from_utf8(out).unwrap(),
1278 String::from_utf8(err).unwrap(),
1279 )
1280 }
1281
1282 #[test]
1283 fn escapes_json() {
1284 assert_eq!(json_string("a\"b\\c\n\u{1}é"), "\"a\\\"b\\\\c\\n\\u0001é\"");
1285 }
1286
1287 #[test]
1288 fn f64_text_forms() {
1289 assert_eq!(f64_text(36.0), "36");
1290 assert_eq!(f64_text(0.0), "0");
1291 assert_eq!(f64_text(1e300), "1e300");
1292 assert_eq!(f64_text(2.5e-7), "2.5e-7");
1293 assert_eq!(f64_text(-0.5), "-0.5");
1294 }
1295
1296 #[test]
1297 fn basic_conversion_and_negative_numbers() {
1298 assert_eq!(
1299 run_str(&["convert", "10", "m/s", "km/h"]),
1300 (0, "36 km/h\n".into(), String::new())
1301 );
1302 assert_eq!(run_str(&["-40", "degF", "degC"]).1, "-40 degC\n");
1303 }
1304
1305 #[test]
1306 fn units_lists_the_table() {
1307 let (code, out, _) = run_str(&["units"]);
1308 assert_eq!(code, 0);
1309 assert!(out.lines().any(|l| l == "## pressure"));
1310 assert!(out.lines().any(|l| l.starts_with("psi\t")));
1311 assert!(out.lines().any(|l| l.starts_with("ton\t")));
1312 let (code, out, _) = run_str(&["units", "--groups"]);
1313 assert_eq!(code, 0);
1314 assert!(out.lines().any(|l| l == "volume\t12"));
1315 let (code, out, _) = run_str(&["units", "psi"]);
1316 assert_eq!(code, 0);
1317 assert!(out.contains("group pressure"));
1318 let (code, out, _) = run_str(&["units", "volume"]);
1319 assert_eq!(code, 0);
1320 assert!(out.contains("gal_us\t") && !out.contains("psi\t"));
1321 assert!(out.contains("\ngal\t"), "ambiguous gal belongs to volume");
1322 let (code, out, _) = run_str(&["units", "--json"]);
1323 assert_eq!(code, 0);
1324 assert!(out.contains("\"schema\":\"dimvexa.units/1\""));
1325 assert_eq!(run_str(&["units", "--exact"]).0, 2);
1326 assert_eq!(run_str(&["units", "nope"]).0, 1);
1327 assert_eq!(run_str(&["units", "--groups", "psi"]).0, 2);
1328 assert_eq!(run_str(&["1", "m", "km", "--groups"]).0, 2);
1329 assert_eq!(run_str(&["units", "length", "mass"]).0, 2);
1330 assert_eq!(run_str(&["convert", "units"]).0, 2);
1331 }
1332
1333 #[test]
1334 fn usage_errors_exit_2() {
1335 assert_eq!(run_str(&[]).0, 2);
1336 assert_eq!(run_str(&["convert", "1", "m"]).0, 2);
1337 assert_eq!(run_str(&["1", "m", "km", "--nope"]).0, 2);
1338 let (code, out, _) = run_str(&["1", "m", "--json"]);
1339 assert_eq!(code, 2);
1340 assert!(out.contains("\"code\":\"usage\""));
1341 }
1342}