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, -1, -1, 0, 0, 0, 0] => "dynamic_viscosity",
597 [0, 2, -1, 0, 0, 0, 0] => "kinematic_viscosity",
598 [1, 2, -2, 0, 0, 0, 0] => "energy",
599 [1, 2, -3, 0, 0, 0, 0] => "power",
600 [0, 0, 1, 1, 0, 0, 0] => "electric_charge",
601 [1, 2, -3, -1, 0, 0, 0] => "voltage",
602 [1, 2, -3, -2, 0, 0, 0] => "resistance",
603 [-1, -2, 4, 2, 0, 0, 0] => "capacitance",
604 [-1, -2, 3, 2, 0, 0, 0] => "conductance",
605 [1, 2, -2, -1, 0, 0, 0] => "magnetic_flux",
606 [1, 0, -2, -1, 0, 0, 0] => "magnetic_flux_density",
607 [1, 2, -2, -2, 0, 0, 0] => "inductance",
608 _ => return format!("dimension[{dim}]"),
609 };
610 name.to_string()
611}
612
613fn select_ambiguous(
616 units: &[(String, &Unit)],
617 filtered: bool,
618) -> Vec<&'static dimvexa::AmbiguousDef> {
619 dimvexa::AMBIGUOUS
620 .iter()
621 .filter(|a| {
622 !filtered
623 || a.candidates
624 .iter()
625 .any(|c| units.iter().any(|(_, u)| u.symbol() == *c))
626 })
627 .collect()
628}
629
630fn user_unit<'a>(user: &'a [ConfigUnit], symbol: &str) -> Option<&'a ConfigUnit> {
632 user.iter().find(|u| u.symbol == symbol)
633}
634
635fn origin_json(u: Option<&ConfigUnit>) -> String {
637 u.map_or_else(
638 || "null".to_string(),
639 |u| {
640 format!(
641 "{{\"file\":{},\"line\":{}}}",
642 json_string(&u.origin),
643 u.line
644 )
645 },
646 )
647}
648
649fn cell(s: &str) -> String {
651 let t: String = s
652 .chars()
653 .map(|c| if c.is_control() { ' ' } else { c })
654 .collect();
655 if t.is_empty() { "-".to_string() } else { t }
656}
657
658fn group_counts(reg: &Registry) -> Vec<(String, usize)> {
661 let mut groups: Vec<(String, usize)> = Vec::new();
662 for u in reg.units() {
663 let g = group_name(u.dim());
664 match groups.iter_mut().find(|(name, _)| *name == g) {
665 Some((_, n)) => *n = n.saturating_add(1),
666 None => groups.push((g, 1)),
667 }
668 }
669 groups
670}
671
672fn groups_text(groups: &[(String, usize)]) -> String {
674 let mut s = String::from("# columns: group, units\n");
675 for (g, n) in groups {
676 let _ = writeln!(s, "{g}\t{n}");
677 }
678 s
679}
680
681fn units_text(
682 units: &[(String, &Unit)],
683 ambiguous: &[&dimvexa::AmbiguousDef],
684 mappings: &[Mapping],
685 bridges: &[Bridge],
686 user: &[ConfigUnit],
687) -> String {
688 let mut s =
689 String::from("# columns: symbol, quantity_kind, exactness, prefixes, aliases, source\n");
690 let mut current: Option<&str> = None;
691 for (g, u) in units {
692 if current != Some(g.as_str()) {
693 let _ = writeln!(s, "\n## {g}");
694 current = Some(g.as_str());
695 }
696 let _ = writeln!(
697 s,
698 "{}\t{}\t{}\t{}\t{}\t{}",
699 cell(u.symbol()),
700 u.kind().name(),
701 u.exactness().name(),
702 u.prefixes().name(),
703 cell(&u.aliases().join(", ")),
704 cell(&user_unit(user, u.symbol()).map_or_else(
705 || u.source().to_string(),
706 |c| format!("{} [yours: {}:{}]", u.source(), c.origin, c.line)
707 ))
708 );
709 }
710 if !ambiguous.is_empty() {
711 s.push_str(
712 "\n## ambiguous symbols (refused unless your config resolves them)\n\
713# columns: symbol, candidates, planned, note\n",
714 );
715 for a in ambiguous {
716 let note = mappings.iter().find(|m| m.name == a.symbol).map_or_else(
717 || a.note.to_string(),
718 |m| {
719 format!(
720 "resolved by config as {} ({}); {}",
721 m.target, m.origin, a.note
722 )
723 },
724 );
725 let _ = writeln!(
726 s,
727 "{}\t{}\t{}\t{}",
728 cell(a.symbol),
729 cell(&a.candidates.join(", ")),
730 cell(&a.planned.join(", ")),
731 cell(¬e)
732 );
733 }
734 }
735 if !mappings.is_empty() {
736 s.push_str(
737 "\n## config mappings (names your --config files resolve)\n# columns: name, unit, file:line\n",
738 );
739 for m in mappings {
740 let _ = writeln!(
741 s,
742 "{}\t{}\t{}:{}",
743 cell(&m.name),
744 cell(&m.target),
745 cell(&m.origin),
746 m.line
747 );
748 }
749 }
750 if !bridges.is_empty() {
751 s.push_str(
752 "\n## config bridges (constants you supply; use with --bridge NAME)\n\
753# columns: name, kind, value, unit, conditions, source, file:line\n",
754 );
755 for b in bridges {
756 let _ = writeln!(
757 s,
758 "{}\t{}\t{}\t{}\t{}\t{}\t{}:{}",
759 cell(&b.name),
760 b.kind.name(),
761 b.value.to_fraction_string(),
762 b.kind.unit(),
763 cell(&b.conditions),
764 cell(&b.source),
765 cell(&b.origin),
766 b.line
767 );
768 }
769 }
770 s
771}
772
773fn units_json(
774 groups: &[(String, usize)],
775 units: &[(String, &Unit)],
776 ambiguous: &[&dimvexa::AmbiguousDef],
777 group: Option<&str>,
778 loaded: &Loaded,
779) -> String {
780 let items: Vec<String> = units
781 .iter()
782 .map(|(g, u)| {
783 let aliases: Vec<String> = u.aliases().iter().map(|a| json_string(a)).collect();
784 let dims: Vec<String> = u.dim().exponents().iter().map(ToString::to_string).collect();
785 format!(
786 "{{\"symbol\":{},\"group\":{},\"aliases\":[{}],\"dimension\":[{}],\"quantity_kind\":{},\"exactness\":{},\"prefixes\":{},\"namespace\":{},\"source\":{},\"origin\":{}}}",
787 json_string(u.symbol()),
788 json_string(g),
789 aliases.join(","),
790 dims.join(","),
791 json_string(u.kind().name()),
792 json_string(u.exactness().name()),
793 json_string(u.prefixes().name()),
794 json_string(u.namespace()),
795 json_string(u.source()),
796 origin_json(user_unit(&loaded.units, u.symbol())),
797 )
798 })
799 .collect();
800 let strs = |v: &[&str]| {
801 v.iter()
802 .map(|x| json_string(x))
803 .collect::<Vec<_>>()
804 .join(",")
805 };
806 let ambiguous: Vec<String> = ambiguous
807 .iter()
808 .map(|a| {
809 format!(
810 "{{\"symbol\":{},\"candidates\":[{}],\"planned\":[{}],\"note\":{},\"resolved_by\":{}}}",
811 json_string(a.symbol),
812 strs(a.candidates),
813 strs(a.planned),
814 json_string(a.note),
815 loaded
816 .mappings
817 .iter()
818 .find(|m| m.name == a.symbol)
819 .map_or_else(
820 || "null".to_string(),
821 |m| format!(
822 "{{\"unit\":{},\"file\":{},\"line\":{}}}",
823 json_string(&m.target),
824 json_string(&m.origin),
825 m.line
826 )
827 )
828 )
829 })
830 .collect();
831 let group_list: Vec<String> = groups
832 .iter()
833 .map(|(g, n)| format!("{{\"name\":{},\"units\":{n}}}", json_string(g)))
834 .collect();
835 let mappings: Vec<String> = loaded
836 .mappings
837 .iter()
838 .map(|m| {
839 format!(
840 "{{\"name\":{},\"unit\":{},\"file\":{},\"line\":{}}}",
841 json_string(&m.name),
842 json_string(&m.target),
843 json_string(&m.origin),
844 m.line
845 )
846 })
847 .collect();
848 let group = group.map_or_else(
849 || "null".to_string(),
850 |g| json_string(&g.to_ascii_lowercase()),
851 );
852 format!(
853 "{{\"schema\":{},\"ok\":true,\"data_version\":{},\"group\":{},\"groups\":[{}],\"units\":[{}],\"ambiguous\":[{}],\"config\":{},\"mappings\":[{}],\"bridges\":[{}]}}",
854 json_string(&format!("{}.units/1", dimvexa::PROJECT_NAME)),
855 json_string(&dimvexa::data_version()),
856 group,
857 group_list.join(","),
858 items.join(","),
859 ambiguous.join(","),
860 configs_json(&loaded.configs),
861 mappings.join(","),
862 loaded
863 .bridges
864 .iter()
865 .map(bridge_json)
866 .collect::<Vec<_>>()
867 .join(",")
868 )
869}
870
871struct Detail {
873 queried: String,
875 symbol: String,
876 group: String,
877 aliases: Vec<String>,
878 dim: Dim,
879 quantity_kind: &'static str,
880 exactness: &'static str,
881 prefixes: Option<&'static str>,
883 namespace: Option<String>,
884 factor: String,
885 offset: String,
886 sources: Vec<String>,
887 note: Option<String>,
888 origin: Option<(String, usize)>,
890 mapping: Option<(String, usize)>,
892}
893
894fn unit_detail(
895 reg: &Registry,
896 user: &[ConfigUnit],
897 mappings: &[Mapping],
898 query: &str,
899) -> Result<Detail, Error> {
900 if let Some(u) = reg.get(query) {
901 return Ok(Detail {
902 queried: query.to_string(),
903 symbol: u.symbol().to_string(),
904 group: group_name(u.dim()),
905 aliases: u.aliases().to_vec(),
906 dim: u.dim(),
907 quantity_kind: u.kind().name(),
908 exactness: u.exactness().name(),
909 prefixes: Some(u.prefixes().name()),
910 namespace: Some(u.namespace().to_string()),
911 factor: u.factor().to_fraction_string(),
912 offset: u.offset().to_fraction_string(),
913 sources: vec![u.source().to_string()],
914 note: Some(u.note().to_string()),
915 origin: user_unit(user, u.symbol()).map(|c| (c.origin.clone(), c.line)),
916 mapping: mappings
917 .iter()
918 .find(|m| m.name == query)
919 .map(|m| (m.origin.clone(), m.line)),
920 });
921 }
922 let e = reg.parse(query)?;
925 Ok(Detail {
926 queried: query.to_string(),
927 symbol: query.to_string(),
928 group: group_name(e.dim()),
929 aliases: Vec::new(),
930 dim: e.dim(),
931 quantity_kind: e.kind().name(),
932 exactness: e.exactness().name(),
933 prefixes: None,
934 namespace: None,
935 factor: e.factor().to_fraction_string(),
936 offset: e.offset().to_fraction_string(),
937 sources: e.sources().to_vec(),
938 note: None,
939 origin: None,
940 mapping: None,
941 })
942}
943
944fn detail_text(d: &Detail) -> String {
945 let none = |s: &str| {
946 if s.is_empty() {
947 "-".to_string()
948 } else {
949 s.to_string()
950 }
951 };
952 let mut s = format!("{}\n", d.symbol);
953 let mut line = |k: &str, v: String| {
954 let _ = writeln!(s, " {k:<14}{}", cell(&v));
955 };
956 line("group", d.group.clone());
957 line("aliases", none(&d.aliases.join(", ")));
958 line("dimension", d.dim.to_string());
959 line("quantity kind", d.quantity_kind.to_string());
960 line("exactness", d.exactness.to_string());
961 if let Some(p) = d.prefixes {
962 line("prefixes", p.to_string());
963 }
964 if let Some(n) = &d.namespace {
965 line("namespace", n.clone());
966 }
967 line("factor to SI", d.factor.clone());
968 line("offset to SI", d.offset.clone());
969 for src in &d.sources {
970 line("source", src.clone());
971 }
972 if let Some(n) = d.note.as_deref().filter(|n| !n.is_empty()) {
973 line("note", n.to_string());
974 }
975 if let Some((file, at)) = &d.mapping {
976 line(
977 "your name",
978 format!(
979 "'{query}' means this unit in your config ({file}:{at})",
980 query = d.queried
981 ),
982 );
983 }
984 if let Some((file, at)) = &d.origin {
985 line(
986 "defined in",
987 format!("{file}:{at} (your config, not the shipped table)"),
988 );
989 }
990 s
991}
992
993fn detail_json(d: &Detail) -> String {
994 let list = |v: &[String]| {
995 v.iter()
996 .map(|x| json_string(x))
997 .collect::<Vec<_>>()
998 .join(",")
999 };
1000 let opt = |v: Option<&str>| v.map_or_else(|| "null".to_string(), json_string);
1001 let dims: Vec<String> = d.dim.exponents().iter().map(ToString::to_string).collect();
1002 format!(
1003 "{{\"schema\":{},\"ok\":true,\"data_version\":{},\"unit\":{{\"symbol\":{},\"group\":{},\"aliases\":[{}],\"dimension\":[{}],\"quantity_kind\":{},\"exactness\":{},\"prefixes\":{},\"namespace\":{},\"factor\":{},\"offset\":{},\"sources\":[{}],\"note\":{},\"origin\":{},\"mapping\":{}}}}}",
1004 json_string(&format!("{}.units/1", dimvexa::PROJECT_NAME)),
1005 json_string(&dimvexa::data_version()),
1006 json_string(&d.symbol),
1007 json_string(&d.group),
1008 list(&d.aliases),
1009 dims.join(","),
1010 json_string(d.quantity_kind),
1011 json_string(d.exactness),
1012 opt(d.prefixes),
1013 opt(d.namespace.as_deref()),
1014 json_string(&d.factor),
1015 json_string(&d.offset),
1016 list(&d.sources),
1017 opt(d.note.as_deref()),
1018 d.origin.as_ref().map_or_else(
1019 || "null".to_string(),
1020 |(f, l)| format!("{{\"file\":{},\"line\":{l}}}", json_string(f)),
1021 ),
1022 d.mapping.as_ref().map_or_else(
1023 || "null".to_string(),
1024 |(f, l)| {
1025 format!(
1026 "{{\"name\":{},\"file\":{},\"line\":{l}}}",
1027 json_string(&d.queried),
1028 json_string(f)
1029 )
1030 },
1031 ),
1032 )
1033}
1034
1035fn run_units(
1037 target: &UnitsTarget,
1038 config_files: &[String],
1039 json: bool,
1040 out: &mut dyn Write,
1041 err: &mut dyn Write,
1042) -> u8 {
1043 let loaded = match load(config_files) {
1044 Ok(l) => l,
1045 Err(e) => return report_load_error(&e, json, "units", out, err),
1046 };
1047 let mut fail = |code: &str, message: &str, suggestions: &[String], status: u8| -> u8 {
1048 if json {
1049 let _ = writeln!(
1050 out,
1051 "{}",
1052 json_error("units", code, message, &[], suggestions)
1053 );
1054 } else {
1055 let _ = writeln!(err, "error[{code}]: {message}");
1056 }
1057 status
1058 };
1059 let reg = &loaded.reg;
1060 let groups = group_counts(reg);
1061 let (selected, group) = match target {
1062 UnitsTarget::Groups => {
1063 let text = if json {
1064 units_json(&groups, &[], &[], None, &loaded)
1065 } else {
1066 groups_text(&groups)
1067 };
1068 let _ = write!(out, "{text}");
1069 if json {
1070 let _ = writeln!(out);
1071 }
1072 return 0;
1073 }
1074 UnitsTarget::All => (None, None),
1075 UnitsTarget::Query(q) => {
1076 let q_lower = q.to_ascii_lowercase();
1077 if groups.iter().any(|(g, _)| *g == q_lower) {
1078 (Some(q_lower.clone()), Some(q_lower))
1079 } else {
1080 return match unit_detail(reg, &loaded.units, &loaded.mappings, q) {
1081 Ok(d) => {
1082 let text = if json {
1083 detail_json(&d)
1084 } else {
1085 detail_text(&d)
1086 };
1087 let _ = write!(out, "{text}");
1088 if json {
1089 let _ = writeln!(out);
1090 }
1091 0
1092 }
1093 Err(e) => {
1094 let hint = if e.code() == "unknown_unit" {
1095 "; it is not a group either (see 'dimvexa units --groups')".to_string()
1096 } else {
1097 String::new()
1098 };
1099 let mut near = e.candidates().to_vec();
1100 for sug in e.suggestions() {
1101 if !near.contains(sug) {
1102 near.push(sug.clone());
1103 }
1104 }
1105 fail(e.code(), &format!("{e}{hint}"), &near, 1)
1106 }
1107 };
1108 }
1109 }
1110 };
1111 let units: Vec<(String, &Unit)> = reg
1112 .units()
1113 .iter()
1114 .map(|u| (group_name(u.dim()), u))
1115 .filter(|(g, _)| selected.as_ref().is_none_or(|want| g == want))
1116 .collect();
1117 let ambiguous = select_ambiguous(&units, group.is_some());
1118 if json {
1119 let _ = writeln!(
1120 out,
1121 "{}",
1122 units_json(&groups, &units, &ambiguous, group.as_deref(), &loaded)
1123 );
1124 } else {
1125 let _ = write!(
1126 out,
1127 "{}",
1128 units_text(
1129 &units,
1130 &ambiguous,
1131 &loaded.mappings,
1132 &loaded.bridges,
1133 &loaded.units,
1134 )
1135 );
1136 }
1137 0
1138}
1139
1140pub fn run<I>(args: I, out: &mut dyn Write, err: &mut dyn Write) -> u8
1143where
1144 I: IntoIterator<Item = String>,
1145{
1146 let args: Vec<String> = args.into_iter().collect();
1147 let (parsed, config_files) = match parse_args(&args) {
1148 Ok(p) => p,
1149 Err(u) => {
1150 if u.json {
1151 let _ = writeln!(
1152 out,
1153 "{}",
1154 json_error(u.schema, "usage", &u.message, &[], &u.suggestions)
1155 );
1156 } else {
1157 let _ = writeln!(err, "error[usage]: {}\n\n{USAGE}", u.message);
1158 }
1159 return 2;
1160 }
1161 };
1162 let args = match parsed {
1163 Parsed::Help => {
1164 let _ = write!(out, "{USAGE}");
1165 return 0;
1166 }
1167 Parsed::Version => {
1168 let _ = writeln!(
1169 out,
1170 "{} {} (data {})",
1171 dimvexa::PROJECT_NAME,
1172 env!("CARGO_PKG_VERSION"),
1173 dimvexa::data_version()
1174 );
1175 return 0;
1176 }
1177 Parsed::Units { target, json } => {
1178 return run_units(&target, &config_files, json, out, err);
1179 }
1180 Parsed::Convert(a) => a,
1181 };
1182 let loaded = match load(&config_files) {
1183 Ok(l) => l,
1184 Err(e) => return report_load_error(&e, args.json, "convert", out, err),
1185 };
1186 if let Some(name) = &args.bridge {
1187 if !loaded.bridges.iter().any(|b| b.name == *name) {
1188 let known: Vec<String> = loaded.bridges.iter().map(|b| b.name.clone()).collect();
1189 let message = if known.is_empty() {
1190 format!(
1191 "no bridge named '{name}': no config with a [[bridge]] is loaded (use --config FILE)"
1192 )
1193 } else {
1194 format!(
1195 "no bridge named '{name}'; loaded bridges: {}",
1196 known.join(", ")
1197 )
1198 };
1199 if args.json {
1200 let _ = writeln!(
1201 out,
1202 "{}",
1203 json_error("convert", "usage", &message, &[], &known)
1204 );
1205 } else {
1206 let _ = writeln!(err, "error[usage]: {message}");
1207 }
1208 return 2;
1209 }
1210 }
1211 match compute(&args, &loaded) {
1212 Ok(outcome) => {
1213 let text = if args.json {
1214 match render_json(&outcome) {
1215 Ok(t) => t,
1216 Err(e) => return fail(&args, &e, out, err),
1217 }
1218 } else {
1219 render_text(&outcome)
1220 };
1221 let _ = writeln!(out, "{text}");
1222 if !args.json {
1223 match (outcome.bridge, &args.bridge) {
1224 (Some(b), _) => {
1225 let _ = writeln!(err, "{}", bridge_note(b));
1226 }
1227 (None, Some(name)) => {
1228 let _ = writeln!(
1229 err,
1230 "note: bridge {name} was not needed: both units have the same dimension"
1231 );
1232 }
1233 (None, None) => {}
1234 }
1235 }
1236 0
1237 }
1238 Err(e) => fail(&args, &e, out, err),
1239 }
1240}
1241
1242fn fail(args: &Args, e: &Error, out: &mut dyn Write, err: &mut dyn Write) -> u8 {
1243 if args.json {
1244 let _ = writeln!(
1245 out,
1246 "{}",
1247 json_error(
1248 "convert",
1249 e.code(),
1250 &e.to_string(),
1251 e.candidates(),
1252 e.suggestions()
1253 )
1254 );
1255 } else {
1256 let _ = writeln!(err, "error[{}]: {e}", e.code());
1257 if let Error::DimensionMismatch { from, to } = e {
1258 if Bridge::density_needed(*from, *to) && args.bridge.is_none() {
1259 let _ = writeln!(
1260 err,
1261 "hint: mass and volume need a density. Define a [[bridge]] in a config file and pass \
1262--config FILE --bridge NAME (docs/adr/009-user-config.md)"
1263 );
1264 }
1265 }
1266 }
1267 1
1268}
1269
1270#[cfg(test)]
1271mod tests {
1272 use super::*;
1273
1274 fn run_str(args: &[&str]) -> (u8, String, String) {
1275 let (mut out, mut err) = (Vec::new(), Vec::new());
1276 let code = run(args.iter().map(ToString::to_string), &mut out, &mut err);
1277 (
1278 code,
1279 String::from_utf8(out).unwrap(),
1280 String::from_utf8(err).unwrap(),
1281 )
1282 }
1283
1284 #[test]
1285 fn escapes_json() {
1286 assert_eq!(json_string("a\"b\\c\n\u{1}é"), "\"a\\\"b\\\\c\\n\\u0001é\"");
1287 }
1288
1289 #[test]
1290 fn f64_text_forms() {
1291 assert_eq!(f64_text(36.0), "36");
1292 assert_eq!(f64_text(0.0), "0");
1293 assert_eq!(f64_text(1e300), "1e300");
1294 assert_eq!(f64_text(2.5e-7), "2.5e-7");
1295 assert_eq!(f64_text(-0.5), "-0.5");
1296 }
1297
1298 #[test]
1299 fn basic_conversion_and_negative_numbers() {
1300 assert_eq!(
1301 run_str(&["convert", "10", "m/s", "km/h"]),
1302 (0, "36 km/h\n".into(), String::new())
1303 );
1304 assert_eq!(run_str(&["-40", "degF", "degC"]).1, "-40 degC\n");
1305 }
1306
1307 #[test]
1308 fn units_lists_the_table() {
1309 let (code, out, _) = run_str(&["units"]);
1310 assert_eq!(code, 0);
1311 assert!(out.lines().any(|l| l == "## pressure"));
1312 assert!(out.lines().any(|l| l.starts_with("psi\t")));
1313 assert!(out.lines().any(|l| l.starts_with("ton\t")));
1314 let (code, out, _) = run_str(&["units", "--groups"]);
1315 assert_eq!(code, 0);
1316 assert!(out.lines().any(|l| l == "volume\t12"));
1317 let (code, out, _) = run_str(&["units", "psi"]);
1318 assert_eq!(code, 0);
1319 assert!(out.contains("group pressure"));
1320 let (code, out, _) = run_str(&["units", "volume"]);
1321 assert_eq!(code, 0);
1322 assert!(out.contains("gal_us\t") && !out.contains("psi\t"));
1323 assert!(out.contains("\ngal\t"), "ambiguous gal belongs to volume");
1324 let (code, out, _) = run_str(&["units", "--json"]);
1325 assert_eq!(code, 0);
1326 assert!(out.contains("\"schema\":\"dimvexa.units/1\""));
1327 assert_eq!(run_str(&["units", "--exact"]).0, 2);
1328 assert_eq!(run_str(&["units", "nope"]).0, 1);
1329 assert_eq!(run_str(&["units", "--groups", "psi"]).0, 2);
1330 assert_eq!(run_str(&["1", "m", "km", "--groups"]).0, 2);
1331 assert_eq!(run_str(&["units", "length", "mass"]).0, 2);
1332 assert_eq!(run_str(&["convert", "units"]).0, 2);
1333 }
1334
1335 #[test]
1336 fn usage_errors_exit_2() {
1337 assert_eq!(run_str(&[]).0, 2);
1338 assert_eq!(run_str(&["convert", "1", "m"]).0, 2);
1339 assert_eq!(run_str(&["1", "m", "km", "--nope"]).0, 2);
1340 let (code, out, _) = run_str(&["1", "m", "--json"]);
1341 assert_eq!(code, 2);
1342 assert!(out.contains("\"code\":\"usage\""));
1343 }
1344}