#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct Span {
pub start: usize,
pub end: usize,
pub line: usize,
pub col: usize,
}
pub struct DepthTracker {
depth: usize,
max_depth: usize,
}
impl DepthTracker {
pub fn with_max_depth(max_depth: usize) -> Self {
Self {
depth: 0,
max_depth,
}
}
pub fn push_depth(&mut self) -> Result<(), usize> {
self.depth += 1;
if self.depth > self.max_depth {
return Err(self.depth);
}
Ok(())
}
pub fn pop_depth(&mut self) {
if self.depth > 0 {
self.depth -= 1;
}
}
pub fn max_depth(&self) -> usize {
self.max_depth
}
}
impl Default for DepthTracker {
fn default() -> Self {
Self {
depth: 0,
max_depth: 5,
}
}
}
use crate::parsing::source::Source;
use rust_decimal::Decimal;
use serde::Serialize;
use std::cmp::Ordering;
use std::fmt;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
pub use crate::literals::{
BooleanValue, CalendarUnit, DateTimeValue, TimeValue, TimezoneValue, Value,
};
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum EffectiveDate {
Origin,
DateTimeValue(crate::DateTimeValue),
}
impl EffectiveDate {
pub fn as_ref(&self) -> Option<&crate::DateTimeValue> {
match self {
EffectiveDate::Origin => None,
EffectiveDate::DateTimeValue(dt) => Some(dt),
}
}
pub fn from_option(opt: Option<crate::DateTimeValue>) -> Self {
match opt {
None => EffectiveDate::Origin,
Some(dt) => EffectiveDate::DateTimeValue(dt),
}
}
pub fn to_option(&self) -> Option<crate::DateTimeValue> {
match self {
EffectiveDate::Origin => None,
EffectiveDate::DateTimeValue(dt) => Some(dt.clone()),
}
}
pub fn is_origin(&self) -> bool {
matches!(self, EffectiveDate::Origin)
}
}
impl PartialOrd for EffectiveDate {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for EffectiveDate {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.as_ref().cmp(&other.as_ref())
}
}
impl fmt::Display for EffectiveDate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
EffectiveDate::Origin => Ok(()),
EffectiveDate::DateTimeValue(dt) => write!(f, "{}", dt),
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct LemmaRepository {
pub name: Option<String>,
pub dependency: Option<String>,
pub start_line: usize,
pub source_type: Option<crate::parsing::source::SourceType>,
}
impl LemmaRepository {
#[must_use]
pub fn new(name: Option<String>) -> Self {
Self {
name,
dependency: None,
start_line: 1,
source_type: None,
}
}
#[must_use]
pub fn with_start_line(mut self, start_line: usize) -> Self {
self.start_line = start_line;
self
}
#[must_use]
pub fn with_source_type(mut self, source_type: crate::parsing::source::SourceType) -> Self {
self.source_type = Some(source_type);
self
}
#[must_use]
pub fn with_dependency(mut self, dependency_id: impl Into<String>) -> Self {
self.dependency = Some(dependency_id.into());
self
}
#[must_use]
pub fn identity(&self) -> Option<&str> {
self.name.as_deref()
}
}
impl PartialEq for LemmaRepository {
fn eq(&self, other: &Self) -> bool {
self.name == other.name
}
}
impl Eq for LemmaRepository {}
impl PartialOrd for LemmaRepository {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for LemmaRepository {
fn cmp(&self, other: &Self) -> Ordering {
self.name.cmp(&other.name)
}
}
impl Hash for LemmaRepository {
fn hash<H: Hasher>(&self, state: &mut H) {
self.name.hash(state);
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct RepositoryQualifier {
pub name: String,
}
impl RepositoryQualifier {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
Self { name: name.into() }
}
#[must_use]
pub fn is_registry(&self) -> bool {
self.name.starts_with('@')
}
}
impl fmt::Display for RepositoryQualifier {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.name)
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct LemmaSpec {
pub name: String,
pub effective_from: EffectiveDate,
pub source_type: Option<crate::parsing::source::SourceType>,
pub start_line: usize,
pub commentary: Option<String>,
pub data: Vec<LemmaData>,
pub rules: Vec<LemmaRule>,
pub meta_fields: Vec<MetaField>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct MetaField {
pub key: String,
pub value: MetaValue,
pub source_location: Source,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MetaValue {
Literal(Value),
Unquoted(String),
}
impl fmt::Display for MetaValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MetaValue::Literal(v) => write!(f, "{}", v),
MetaValue::Unquoted(s) => write!(f, "{}", s),
}
}
}
impl fmt::Display for MetaField {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "meta {}: {}", self.key, self.value)
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct LemmaData {
pub reference: Reference,
pub value: DataValue,
pub source_location: Source,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct UnlessClause {
pub condition: Expression,
pub result: Expression,
pub source_location: Source,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct LemmaRule {
pub name: String,
pub expression: Expression,
pub unless_clauses: Vec<UnlessClause>,
pub source_location: Source,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Expression {
pub kind: ExpressionKind,
pub source_location: Option<Source>,
}
impl Expression {
#[must_use]
pub fn new(kind: ExpressionKind, source_location: Source) -> Self {
Self {
kind,
source_location: Some(source_location),
}
}
}
impl PartialEq for Expression {
fn eq(&self, other: &Self) -> bool {
self.kind == other.kind
}
}
impl Eq for Expression {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DateRelativeKind {
InPast,
InFuture,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DateCalendarKind {
Current,
Past,
Future,
NotIn,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CalendarPeriodUnit {
Year,
Month,
Week,
}
impl CalendarPeriodUnit {
#[must_use]
pub fn from_keyword(s: &str) -> Option<Self> {
match s.trim().to_lowercase().as_str() {
"year" | "years" => Some(Self::Year),
"month" | "months" => Some(Self::Month),
"week" | "weeks" => Some(Self::Week),
_ => None,
}
}
}
impl fmt::Display for DateRelativeKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DateRelativeKind::InPast => write!(f, "in past"),
DateRelativeKind::InFuture => write!(f, "in future"),
}
}
}
impl fmt::Display for DateCalendarKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DateCalendarKind::Current => write!(f, "in calendar"),
DateCalendarKind::Past => write!(f, "in past calendar"),
DateCalendarKind::Future => write!(f, "in future calendar"),
DateCalendarKind::NotIn => write!(f, "not in calendar"),
}
}
}
impl fmt::Display for CalendarPeriodUnit {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CalendarPeriodUnit::Year => write!(f, "year"),
CalendarPeriodUnit::Month => write!(f, "month"),
CalendarPeriodUnit::Week => write!(f, "week"),
}
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExpressionKind {
Literal(Value),
Reference(Reference),
Now,
DateRelative(DateRelativeKind, Arc<Expression>),
DateCalendar(DateCalendarKind, CalendarPeriodUnit, Arc<Expression>),
RangeLiteral(Arc<Expression>, Arc<Expression>),
PastFutureRange(DateRelativeKind, Arc<Expression>),
RangeContainment(Arc<Expression>, Arc<Expression>),
LogicalAnd(Arc<Expression>, Arc<Expression>),
Arithmetic(Arc<Expression>, ArithmeticComputation, Arc<Expression>),
Comparison(Arc<Expression>, ComparisonComputation, Arc<Expression>),
UnitConversion(Arc<Expression>, ConversionTarget),
LogicalNegation(Arc<Expression>, NegationType),
MathematicalComputation(MathematicalComputation, Arc<Expression>),
Veto(VetoExpression),
ResultIsVeto(Arc<Expression>),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct Reference {
pub segments: Vec<String>,
pub name: String,
}
impl Reference {
#[must_use]
pub fn local(name: String) -> Self {
Self {
segments: Vec::new(),
name,
}
}
#[must_use]
pub fn from_path(path: Vec<String>) -> Self {
if path.is_empty() {
Self {
segments: Vec::new(),
name: String::new(),
}
} else {
let name = path[path.len() - 1].clone();
let segments = path[..path.len() - 1].to_vec();
Self { segments, name }
}
}
#[must_use]
pub fn is_local(&self) -> bool {
self.segments.is_empty()
}
#[must_use]
pub fn full_path(&self) -> Vec<String> {
let mut path = self.segments.clone();
path.push(self.name.clone());
path
}
}
impl fmt::Display for Reference {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for segment in &self.segments {
write!(f, "{}.", segment)?;
}
write!(f, "{}", self.name)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ArithmeticComputation {
Add,
Subtract,
Multiply,
Divide,
Modulo,
Power,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComparisonComputation {
GreaterThan,
LessThan,
GreaterThanOrEqual,
LessThanOrEqual,
Is,
IsNot,
}
impl ComparisonComputation {
#[must_use]
pub fn is_equal(&self) -> bool {
matches!(self, ComparisonComputation::Is)
}
#[must_use]
pub fn is_not_equal(&self) -> bool {
matches!(self, ComparisonComputation::IsNot)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConversionTarget {
Calendar(CalendarUnit),
Unit(String),
Type(PrimitiveKind),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NegationType {
Not,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct VetoExpression {
pub message: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MathematicalComputation {
Sqrt,
Sin,
Cos,
Tan,
Asin,
Acos,
Atan,
Log,
Exp,
Abs,
Floor,
Ceil,
Round,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct SpecRef {
pub repository: Option<RepositoryQualifier>,
pub name: String,
pub effective: Option<DateTimeValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository_span: Option<Span>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_span: Option<Span>,
}
impl std::fmt::Display for SpecRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(qualifier) = &self.repository {
write!(f, "{} ", qualifier)?;
}
write!(f, "{}", self.name)?;
if let Some(d) = &self.effective {
write!(f, " {}", d)?;
}
Ok(())
}
}
impl SpecRef {
pub fn same_repository(name: impl Into<String>) -> Self {
Self {
name: name.into(),
repository: None,
effective: None,
repository_span: None,
target_span: None,
}
}
pub fn cross_repository(name: impl Into<String>, qualifier: RepositoryQualifier) -> Self {
Self {
name: name.into(),
repository: Some(qualifier),
effective: None,
repository_span: None,
target_span: None,
}
}
pub fn at(&self, effective: &EffectiveDate) -> EffectiveDate {
self.effective
.clone()
.map_or_else(|| effective.clone(), EffectiveDate::DateTimeValue)
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct UnitFactor {
pub quantity_ref: String,
pub exp: i32,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum UnitArg {
Factor(Decimal),
Expr(Decimal, Vec<UnitFactor>),
}
impl fmt::Display for UnitArg {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
UnitArg::Factor(v) => write!(f, "{}", v),
UnitArg::Expr(prefix, factors) => {
if *prefix != Decimal::ONE {
write!(f, "{} ", prefix)?;
}
for (index, factor) in factors.iter().enumerate() {
if factor.exp == 0 {
unreachable!("BUG: unit factor exponent cannot be zero");
}
if factor.exp > 0 {
if index > 0 {
write!(f, " * ")?;
}
write!(f, "{}", factor.quantity_ref)?;
if factor.exp != 1 {
write!(f, "^{}", factor.exp)?;
}
} else {
let denominator_started =
factors[..index].iter().any(|prior| prior.exp < 0);
if denominator_started {
write!(f, " * ")?;
} else {
write!(f, "/")?;
}
write!(f, "{}", factor.quantity_ref)?;
let positive_exp = factor
.exp
.checked_neg()
.expect("BUG: negative unit factor exponent");
if positive_exp != 1 {
write!(f, "^{}", positive_exp)?;
}
}
}
Ok(())
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum CommandArg {
Literal(crate::literals::Value),
Label(String),
UnitExpr(UnitArg),
}
impl fmt::Display for CommandArg {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CommandArg::Literal(v) => write!(f, "{}", v),
CommandArg::Label(s) => write!(f, "{}", s),
CommandArg::UnitExpr(unit_arg) => write!(f, "{}", unit_arg),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TypeConstraintCommand {
Help,
Default,
Unit,
Trait,
Minimum,
Maximum,
Decimals,
Option,
Options,
Length,
}
impl fmt::Display for TypeConstraintCommand {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
TypeConstraintCommand::Help => "help",
TypeConstraintCommand::Default => "default",
TypeConstraintCommand::Unit => "unit",
TypeConstraintCommand::Trait => "trait",
TypeConstraintCommand::Minimum => "minimum",
TypeConstraintCommand::Maximum => "maximum",
TypeConstraintCommand::Decimals => "decimals",
TypeConstraintCommand::Option => "option",
TypeConstraintCommand::Options => "options",
TypeConstraintCommand::Length => "length",
};
write!(f, "{}", s)
}
}
#[must_use]
pub fn try_parse_type_constraint_command(s: &str) -> Option<TypeConstraintCommand> {
match s.trim().to_lowercase().as_str() {
"help" => Some(TypeConstraintCommand::Help),
"default" => Some(TypeConstraintCommand::Default),
"unit" => Some(TypeConstraintCommand::Unit),
"trait" => Some(TypeConstraintCommand::Trait),
"minimum" => Some(TypeConstraintCommand::Minimum),
"maximum" => Some(TypeConstraintCommand::Maximum),
"decimals" => Some(TypeConstraintCommand::Decimals),
"option" => Some(TypeConstraintCommand::Option),
"options" => Some(TypeConstraintCommand::Options),
"length" => Some(TypeConstraintCommand::Length),
_ => None,
}
}
pub type Constraint = (TypeConstraintCommand, Vec<CommandArg>);
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FillRhs {
Literal(Value),
Reference { target: Reference },
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DataValue {
Definition {
#[serde(default, skip_serializing_if = "Option::is_none")]
base: Option<ParentType>,
constraints: Option<Vec<Constraint>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
value: Option<Value>,
},
Import(SpecRef),
Fill(FillRhs),
}
impl DataValue {
#[must_use]
pub fn is_definition_literal_only(&self) -> bool {
matches!(
self,
DataValue::Definition {
base: None,
constraints: None,
value: Some(_),
}
)
}
#[must_use]
pub fn definition_needs_type_resolution(&self) -> bool {
match self {
DataValue::Definition { base: Some(_), .. }
| DataValue::Definition {
constraints: Some(_),
..
} => true,
DataValue::Definition {
base: None,
constraints: None,
value: Some(v),
} => !matches!(v, Value::NumberWithUnit(_, _)),
DataValue::Import(_) | DataValue::Fill(_) | DataValue::Definition { .. } => false,
}
}
}
fn format_constraint_chain(constraints: &[Constraint]) -> String {
constraints
.iter()
.map(|(cmd, args)| {
let args_str: Vec<String> = args.iter().map(|a| a.to_string()).collect();
let joined = args_str.join(" ");
if joined.is_empty() {
format!("{}", cmd)
} else {
format!("{} {}", cmd, joined)
}
})
.collect::<Vec<_>>()
.join(" -> ")
}
impl fmt::Display for DataValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DataValue::Definition {
base,
constraints,
value,
} => {
if base.is_none() && constraints.is_none() {
return match value {
Some(v) => write!(f, "{}", v),
None => Ok(()),
};
}
let base_str = match base.as_ref() {
Some(b) => format!("{b}"),
None => match value {
Some(v) => {
if let Some(ref constraints_vec) = constraints {
let constraint_str = format_constraint_chain(constraints_vec);
return write!(f, "{v} -> {constraint_str}");
}
return write!(f, "{v}");
}
None => String::new(),
},
};
if let Some(ref constraints_vec) = constraints {
let constraint_str = format_constraint_chain(constraints_vec);
write!(f, "{base_str} -> {constraint_str}")
} else {
write!(f, "{base_str}")
}
}
DataValue::Import(spec_ref) => {
write!(f, "with {}", spec_ref)
}
DataValue::Fill(fill_rhs) => match fill_rhs {
FillRhs::Literal(v) => write!(f, "{v}"),
FillRhs::Reference { target } => write!(f, "{target}"),
},
}
}
}
impl LemmaData {
#[must_use]
pub fn new(reference: Reference, value: DataValue, source_location: Source) -> Self {
Self {
reference,
value,
source_location,
}
}
}
impl LemmaSpec {
#[must_use]
pub fn new(name: String) -> Self {
Self {
name,
effective_from: EffectiveDate::Origin,
source_type: None,
start_line: 1,
commentary: None,
data: Vec::new(),
rules: Vec::new(),
meta_fields: Vec::new(),
}
}
pub fn effective_from(&self) -> Option<&DateTimeValue> {
self.effective_from.as_ref()
}
#[must_use]
pub fn with_source_type(mut self, source_type: crate::parsing::source::SourceType) -> Self {
self.source_type = Some(source_type);
self
}
#[must_use]
pub fn with_start_line(mut self, start_line: usize) -> Self {
self.start_line = start_line;
self
}
#[must_use]
pub fn set_commentary(mut self, commentary: String) -> Self {
self.commentary = Some(commentary);
self
}
#[must_use]
pub fn add_data(mut self, data: LemmaData) -> Self {
self.data.push(data);
self
}
#[must_use]
pub fn add_rule(mut self, rule: LemmaRule) -> Self {
self.rules.push(rule);
self
}
#[must_use]
pub fn add_meta_field(mut self, meta: MetaField) -> Self {
self.meta_fields.push(meta);
self
}
}
impl fmt::Display for LemmaSpec {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "spec {}", self.name)?;
if let EffectiveDate::DateTimeValue(ref af) = self.effective_from {
write!(f, " {}", af)?;
}
writeln!(f)?;
if let Some(ref commentary) = self.commentary {
writeln!(f, "\"\"\"")?;
writeln!(f, "{}", commentary)?;
writeln!(f, "\"\"\"")?;
}
if !self.data.is_empty() {
writeln!(f)?;
for data in &self.data {
write!(f, "{}", data)?;
}
}
if !self.rules.is_empty() {
writeln!(f)?;
for (index, rule) in self.rules.iter().enumerate() {
if index > 0 {
writeln!(f)?;
}
write!(f, "{}", rule)?;
}
}
if !self.meta_fields.is_empty() {
writeln!(f)?;
for meta in &self.meta_fields {
writeln!(f, "{}", meta)?;
}
}
Ok(())
}
}
impl fmt::Display for LemmaData {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "data {}: {}", self.reference, self.value)
}
}
impl fmt::Display for LemmaRule {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "rule {}: {}", self.name, self.expression)?;
for unless_clause in &self.unless_clauses {
write!(
f,
"\n unless {} then {}",
unless_clause.condition, unless_clause.result
)?;
}
writeln!(f)?;
Ok(())
}
}
pub fn expression_precedence(kind: &ExpressionKind) -> u8 {
match kind {
ExpressionKind::LogicalAnd(..) => 2,
ExpressionKind::LogicalNegation(..) => 3,
ExpressionKind::Comparison(..) | ExpressionKind::ResultIsVeto(..) => 4,
ExpressionKind::RangeContainment(..) => 4,
ExpressionKind::DateRelative(..) | ExpressionKind::DateCalendar(..) => 4,
ExpressionKind::Arithmetic(_, op, _) => match op {
ArithmeticComputation::Add | ArithmeticComputation::Subtract => 5,
ArithmeticComputation::Multiply
| ArithmeticComputation::Divide
| ArithmeticComputation::Modulo => 6,
ArithmeticComputation::Power => 7,
},
ExpressionKind::UnitConversion(..) => 8,
ExpressionKind::RangeLiteral(..) => 9,
ExpressionKind::MathematicalComputation(..) => 10,
ExpressionKind::PastFutureRange(..) => 10,
ExpressionKind::Literal(..)
| ExpressionKind::Reference(..)
| ExpressionKind::Now
| ExpressionKind::Veto(..) => 10,
}
}
fn write_expression_child(
f: &mut fmt::Formatter<'_>,
child: &Expression,
parent_prec: u8,
) -> fmt::Result {
let child_prec = expression_precedence(&child.kind);
if child_prec < parent_prec {
write!(f, "({})", child)
} else {
write!(f, "{}", child)
}
}
impl fmt::Display for Expression {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.kind {
ExpressionKind::Literal(lit) => write!(f, "{}", AsLemmaSource(lit)),
ExpressionKind::Reference(r) => write!(f, "{}", r),
ExpressionKind::Arithmetic(left, op, right) => {
let my_prec = expression_precedence(&self.kind);
write_expression_child(f, left, my_prec)?;
write!(f, " {} ", op)?;
write_expression_child(f, right, my_prec)
}
ExpressionKind::Comparison(left, op, right) => {
let my_prec = expression_precedence(&self.kind);
write_expression_child(f, left, my_prec)?;
write!(f, " {} ", op)?;
write_expression_child(f, right, my_prec)
}
ExpressionKind::UnitConversion(value, target) => {
let my_prec = expression_precedence(&self.kind);
write_expression_child(f, value, my_prec)?;
write!(f, " as {}", target)
}
ExpressionKind::LogicalNegation(expr, negation) => {
if let (NegationType::Not, ExpressionKind::ResultIsVeto(operand)) =
(negation, &expr.kind)
{
let my_prec = expression_precedence(&self.kind);
write_expression_child(f, operand, my_prec)?;
write!(f, " is not veto")
} else {
let my_prec = expression_precedence(&self.kind);
write!(f, "not ")?;
write_expression_child(f, expr, my_prec)
}
}
ExpressionKind::ResultIsVeto(operand) => {
let my_prec = expression_precedence(&self.kind);
write_expression_child(f, operand, my_prec)?;
write!(f, " is veto")
}
ExpressionKind::LogicalAnd(left, right) => {
let my_prec = expression_precedence(&self.kind);
write_expression_child(f, left, my_prec)?;
write!(f, " and ")?;
write_expression_child(f, right, my_prec)
}
ExpressionKind::MathematicalComputation(op, operand) => {
let my_prec = expression_precedence(&self.kind);
write!(f, "{} ", op)?;
write_expression_child(f, operand, my_prec)
}
ExpressionKind::Veto(veto) => match &veto.message {
Some(msg) => write!(f, "veto {}", quote_lemma_text(msg)),
None => write!(f, "veto"),
},
ExpressionKind::Now => write!(f, "now"),
ExpressionKind::DateRelative(kind, date_expr) => {
write!(f, "{} {}", date_expr, kind)?;
Ok(())
}
ExpressionKind::DateCalendar(kind, unit, date_expr) => {
write!(f, "{} {} {}", date_expr, kind, unit)
}
ExpressionKind::RangeLiteral(left, right) => {
let my_prec = expression_precedence(&self.kind);
write_expression_child(f, left, my_prec)?;
write!(f, "...")?;
write_expression_child(f, right, my_prec)
}
ExpressionKind::PastFutureRange(kind, offset_expr) => {
write!(f, "{} ", kind)?;
let my_prec = expression_precedence(&self.kind);
write_expression_child(f, offset_expr, my_prec)
}
ExpressionKind::RangeContainment(value, range) => {
let my_prec = expression_precedence(&self.kind);
write_expression_child(f, value, my_prec)?;
write!(f, " in ")?;
write_expression_child(f, range, my_prec)
}
}
}
}
impl fmt::Display for ConversionTarget {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConversionTarget::Calendar(unit) => write!(f, "{}", unit),
ConversionTarget::Unit(unit) => write!(f, "{}", unit),
ConversionTarget::Type(kind) => write!(f, "{:?}", kind),
}
}
}
impl fmt::Display for ArithmeticComputation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ArithmeticComputation::Add => write!(f, "+"),
ArithmeticComputation::Subtract => write!(f, "-"),
ArithmeticComputation::Multiply => write!(f, "*"),
ArithmeticComputation::Divide => write!(f, "/"),
ArithmeticComputation::Modulo => write!(f, "%"),
ArithmeticComputation::Power => write!(f, "^"),
}
}
}
impl fmt::Display for ComparisonComputation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ComparisonComputation::GreaterThan => write!(f, ">"),
ComparisonComputation::LessThan => write!(f, "<"),
ComparisonComputation::GreaterThanOrEqual => write!(f, ">="),
ComparisonComputation::LessThanOrEqual => write!(f, "<="),
ComparisonComputation::Is => write!(f, "is"),
ComparisonComputation::IsNot => write!(f, "is not"),
}
}
}
impl fmt::Display for MathematicalComputation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MathematicalComputation::Sqrt => write!(f, "sqrt"),
MathematicalComputation::Sin => write!(f, "sin"),
MathematicalComputation::Cos => write!(f, "cos"),
MathematicalComputation::Tan => write!(f, "tan"),
MathematicalComputation::Asin => write!(f, "asin"),
MathematicalComputation::Acos => write!(f, "acos"),
MathematicalComputation::Atan => write!(f, "atan"),
MathematicalComputation::Log => write!(f, "log"),
MathematicalComputation::Exp => write!(f, "exp"),
MathematicalComputation::Abs => write!(f, "abs"),
MathematicalComputation::Floor => write!(f, "floor"),
MathematicalComputation::Ceil => write!(f, "ceil"),
MathematicalComputation::Round => write!(f, "round"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PrimitiveKind {
Boolean,
Quantity,
QuantityRange,
Number,
NumberRange,
Percent,
Ratio,
RatioRange,
Text,
Date,
DateRange,
Time,
Calendar,
CalendarRange,
}
impl std::fmt::Display for PrimitiveKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
PrimitiveKind::Boolean => "boolean",
PrimitiveKind::Quantity => "quantity",
PrimitiveKind::QuantityRange => "quantity range",
PrimitiveKind::Number => "number",
PrimitiveKind::NumberRange => "number range",
PrimitiveKind::Percent => "percent",
PrimitiveKind::Ratio => "ratio",
PrimitiveKind::RatioRange => "ratio range",
PrimitiveKind::Text => "text",
PrimitiveKind::Date => "date",
PrimitiveKind::DateRange => "date range",
PrimitiveKind::Time => "time",
PrimitiveKind::Calendar => "calendar",
PrimitiveKind::CalendarRange => "calendar range",
};
write!(f, "{}", s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ParentType {
Primitive {
primitive: PrimitiveKind,
},
Custom {
name: String,
},
Qualified {
spec_alias: String,
inner: Box<ParentType>,
},
}
impl std::fmt::Display for ParentType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ParentType::Primitive { primitive } => write!(f, "{}", primitive),
ParentType::Custom { name } => write!(f, "{}", name),
ParentType::Qualified { spec_alias, inner } => {
write!(f, "{spec_alias}.{inner}")
}
}
}
}
pub struct AsLemmaSource<'a, T: ?Sized>(pub &'a T);
pub fn quote_lemma_text(s: &str) -> String {
let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{}\"", escaped)
}
fn format_decimal_source(n: &Decimal) -> String {
let raw = if n.fract().is_zero() {
n.trunc().to_string()
} else {
n.to_string()
};
group_digits(&raw)
}
fn group_digits(s: &str) -> String {
let (sign, rest) = if s.starts_with('-') || s.starts_with('+') {
(&s[..1], &s[1..])
} else {
("", s)
};
let (int_part, frac_part) = match rest.find('.') {
Some(pos) => (&rest[..pos], &rest[pos..]),
None => (rest, ""),
};
if int_part.len() < 4 {
return s.to_string();
}
let mut grouped = String::with_capacity(int_part.len() + int_part.len() / 3);
for (i, ch) in int_part.chars().enumerate() {
let digits_remaining = int_part.len() - i;
if i > 0 && digits_remaining % 3 == 0 {
grouped.push('_');
}
grouped.push(ch);
}
format!("{}{}{}", sign, grouped, frac_part)
}
impl<'a> fmt::Display for AsLemmaSource<'a, CommandArg> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use crate::literals::Value;
match self.0 {
CommandArg::Literal(Value::Text(s)) => write!(f, "{}", quote_lemma_text(s)),
CommandArg::Literal(Value::Number(d)) => {
write!(f, "{}", group_digits(&d.to_string()))
}
CommandArg::Literal(Value::Boolean(bv)) => write!(f, "{}", bv),
CommandArg::Literal(Value::NumberWithUnit(d, unit)) => {
write!(f, "{} {}", group_digits(&d.to_string()), unit)
}
CommandArg::Literal(Value::Calendar(d, unit)) => {
write!(f, "{} {}", group_digits(&d.to_string()), unit)
}
CommandArg::Literal(value @ Value::Range(_, _)) => {
write!(f, "{}", AsLemmaSource(value))
}
CommandArg::Literal(Value::Date(dt)) => write!(f, "{}", dt),
CommandArg::Literal(Value::Time(t)) => write!(f, "{}", t),
CommandArg::Label(s) => write!(f, "{}", s),
CommandArg::UnitExpr(unit_arg) => write!(f, "{}", unit_arg),
}
}
}
pub(crate) fn format_constraint_as_source(
cmd: &TypeConstraintCommand,
args: &[CommandArg],
) -> String {
if args.is_empty() {
cmd.to_string()
} else {
let args_str: Vec<String> = args
.iter()
.map(|a| format!("{}", AsLemmaSource(a)))
.collect();
format!("{} {}", cmd, args_str.join(" "))
}
}
fn format_constraints_as_source(constraints: &[Constraint], separator: &str) -> String {
constraints
.iter()
.map(|(cmd, args)| format_constraint_as_source(cmd, args))
.collect::<Vec<_>>()
.join(separator)
}
impl<'a> fmt::Display for AsLemmaSource<'a, Value> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.0 {
Value::Number(n) => write!(f, "{}", format_decimal_source(n)),
Value::Text(s) => write!(f, "{}", quote_lemma_text(s)),
Value::Date(dt) => {
let is_date_only =
dt.hour == 0 && dt.minute == 0 && dt.second == 0 && dt.timezone.is_none();
if is_date_only {
write!(f, "{:04}-{:02}-{:02}", dt.year, dt.month, dt.day)
} else {
write!(
f,
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second
)?;
if let Some(tz) = &dt.timezone {
write!(f, "{}", tz)?;
}
Ok(())
}
}
Value::Time(t) => {
write!(f, "{:02}:{:02}:{:02}", t.hour, t.minute, t.second)?;
if let Some(tz) = &t.timezone {
write!(f, "{}", tz)?;
}
Ok(())
}
Value::Boolean(b) => write!(f, "{}", b),
Value::NumberWithUnit(n, u) => match u.as_str() {
"percent" => write!(f, "{}%", format_decimal_source(n)),
"permille" => write!(f, "{}%%", format_decimal_source(n)),
unit => write!(f, "{} {}", format_decimal_source(n), unit),
},
Value::Calendar(n, u) => write!(f, "{} {}", format_decimal_source(n), u),
Value::Range(left, right) => {
write!(
f,
"{}...{}",
AsLemmaSource(left.as_ref()),
AsLemmaSource(right.as_ref())
)
}
}
}
}
impl<'a> fmt::Display for AsLemmaSource<'a, MetaValue> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.0 {
MetaValue::Literal(v) => write!(f, "{}", AsLemmaSource(v)),
MetaValue::Unquoted(s) => write!(f, "{}", s),
}
}
}
impl<'a> fmt::Display for AsLemmaSource<'a, DataValue> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.0 {
DataValue::Definition {
base,
constraints,
value,
} => {
if base.is_none() && constraints.is_none() {
if let Some(v) = value {
return write!(f, "{}", AsLemmaSource(v));
}
}
let base_str = match base.as_ref() {
Some(b) => format!("{}", b),
None => match value {
Some(v) => {
if let Some(ref constraints_vec) = constraints {
let constraint_str =
format_constraints_as_source(constraints_vec, " -> ");
return write!(f, "{} -> {}", AsLemmaSource(v), constraint_str);
}
return write!(f, "{}", AsLemmaSource(v));
}
None => String::new(),
},
};
if let Some(ref constraints_vec) = constraints {
let constraint_str = format_constraints_as_source(constraints_vec, " -> ");
write!(f, "{} -> {}", base_str, constraint_str)
} else {
write!(f, "{}", base_str)
}
}
DataValue::Import(spec_ref) => {
write!(f, "with {}", spec_ref)
}
DataValue::Fill(fill_rhs) => match fill_rhs {
FillRhs::Literal(v) => write!(f, "{}", AsLemmaSource(v)),
FillRhs::Reference { target } => write!(f, "{target}"),
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_conversion_target_display() {
assert_eq!(
format!("{}", ConversionTarget::Unit("hours".to_string())),
"hours"
);
assert_eq!(
format!("{}", ConversionTarget::Unit("usd".to_string())),
"usd"
);
}
#[test]
fn test_value_number_with_unit_ratio_display() {
use rust_decimal::Decimal;
use std::str::FromStr;
let percent =
Value::NumberWithUnit(Decimal::from_str("10").unwrap(), "percent".to_string());
assert_eq!(format!("{}", percent), "10%");
let permille =
Value::NumberWithUnit(Decimal::from_str("5").unwrap(), "permille".to_string());
assert_eq!(format!("{}", permille), "5%%");
}
#[test]
fn test_datetime_value_display() {
let dt = DateTimeValue {
year: 2024,
month: 12,
day: 25,
hour: 14,
minute: 30,
second: 45,
microsecond: 0,
timezone: Some(TimezoneValue {
offset_hours: 1,
offset_minutes: 0,
}),
};
assert_eq!(format!("{}", dt), "2024-12-25T14:30:45+01:00");
}
#[test]
fn test_datetime_value_display_date_only() {
let dt = DateTimeValue {
year: 2026,
month: 3,
day: 4,
hour: 0,
minute: 0,
second: 0,
microsecond: 0,
timezone: None,
};
assert_eq!(format!("{}", dt), "2026-03-04");
}
#[test]
fn test_datetime_value_display_microseconds() {
let dt = DateTimeValue {
year: 2026,
month: 2,
day: 23,
hour: 14,
minute: 30,
second: 45,
microsecond: 123456,
timezone: Some(TimezoneValue {
offset_hours: 0,
offset_minutes: 0,
}),
};
assert_eq!(format!("{}", dt), "2026-02-23T14:30:45.123456Z");
}
#[test]
fn test_datetime_microsecond_in_ordering() {
let a = DateTimeValue {
year: 2026,
month: 1,
day: 1,
hour: 0,
minute: 0,
second: 0,
microsecond: 100,
timezone: None,
};
let b = DateTimeValue {
year: 2026,
month: 1,
day: 1,
hour: 0,
minute: 0,
second: 0,
microsecond: 200,
timezone: None,
};
assert!(a < b);
}
#[test]
fn test_datetime_parse_iso_week() {
let dt: DateTimeValue = "2026-W01".parse().unwrap();
assert_eq!(dt.year, 2025);
assert_eq!(dt.month, 12);
assert_eq!(dt.day, 29);
assert_eq!(dt.microsecond, 0);
}
#[test]
fn test_negation_types() {
let json = serde_json::to_string(&NegationType::Not).expect("serialize NegationType");
let decoded: NegationType = serde_json::from_str(&json).expect("deserialize NegationType");
assert_eq!(decoded, NegationType::Not);
}
#[test]
fn parent_type_primitive_serde_internally_tagged() {
let p = ParentType::Primitive {
primitive: PrimitiveKind::Number,
};
let json = serde_json::to_string(&p).expect("ParentType::Primitive must serialize");
assert!(json.contains("\"kind\"") && json.contains("\"primitive\""));
let back: ParentType = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, p);
}
fn text_arg(s: &str) -> CommandArg {
CommandArg::Literal(crate::literals::Value::Text(s.to_string()))
}
fn number_arg(s: &str) -> CommandArg {
let d: rust_decimal::Decimal = s.parse().expect("decimal");
CommandArg::Literal(crate::literals::Value::Number(d))
}
fn boolean_arg(b: BooleanValue) -> CommandArg {
CommandArg::Literal(crate::literals::Value::Boolean(b))
}
fn quantity_arg(value: &str, unit: &str) -> CommandArg {
let d: rust_decimal::Decimal = value.parse().expect("decimal");
CommandArg::Literal(crate::literals::Value::NumberWithUnit(d, unit.to_string()))
}
fn duration_arg(value: &str, unit: &str) -> CommandArg {
let d: rust_decimal::Decimal = value.parse().expect("decimal");
CommandArg::Literal(crate::literals::Value::NumberWithUnit(d, unit.to_string()))
}
#[test]
fn as_lemma_source_text_default_is_quoted() {
let fv = DataValue::Definition {
base: Some(ParentType::Primitive {
primitive: PrimitiveKind::Text,
}),
constraints: Some(vec![(
TypeConstraintCommand::Default,
vec![text_arg("single")],
)]),
value: None,
};
assert_eq!(
format!("{}", AsLemmaSource(&fv)),
"text -> default \"single\""
);
}
#[test]
fn as_lemma_source_number_default_not_quoted() {
let fv = DataValue::Definition {
base: Some(ParentType::Primitive {
primitive: PrimitiveKind::Number,
}),
constraints: Some(vec![(
TypeConstraintCommand::Default,
vec![number_arg("10")],
)]),
value: None,
};
assert_eq!(format!("{}", AsLemmaSource(&fv)), "number -> default 10");
}
#[test]
fn as_lemma_source_help_always_quoted() {
let fv = DataValue::Definition {
base: Some(ParentType::Primitive {
primitive: PrimitiveKind::Number,
}),
constraints: Some(vec![(
TypeConstraintCommand::Help,
vec![text_arg("Enter a quantity")],
)]),
value: None,
};
assert_eq!(
format!("{}", AsLemmaSource(&fv)),
"number -> help \"Enter a quantity\""
);
}
#[test]
fn as_lemma_source_text_option_quoted() {
let fv = DataValue::Definition {
base: Some(ParentType::Primitive {
primitive: PrimitiveKind::Text,
}),
constraints: Some(vec![
(TypeConstraintCommand::Option, vec![text_arg("active")]),
(TypeConstraintCommand::Option, vec![text_arg("inactive")]),
]),
value: None,
};
assert_eq!(
format!("{}", AsLemmaSource(&fv)),
"text -> option \"active\" -> option \"inactive\""
);
}
#[test]
fn as_lemma_source_quantity_unit_not_quoted() {
let fv = DataValue::Definition {
base: Some(ParentType::Primitive {
primitive: PrimitiveKind::Quantity,
}),
constraints: Some(vec![
(
TypeConstraintCommand::Unit,
vec![CommandArg::Label("eur".to_string()), number_arg("1.00")],
),
(
TypeConstraintCommand::Unit,
vec![CommandArg::Label("usd".to_string()), number_arg("0.91")],
),
]),
value: None,
};
assert_eq!(
format!("{}", AsLemmaSource(&fv)),
"quantity -> unit eur 1.00 -> unit usd 0.91"
);
}
#[test]
fn as_lemma_source_quantity_minimum_with_unit() {
let fv = DataValue::Definition {
base: Some(ParentType::Primitive {
primitive: PrimitiveKind::Quantity,
}),
constraints: Some(vec![(
TypeConstraintCommand::Minimum,
vec![quantity_arg("0", "eur")],
)]),
value: None,
};
assert_eq!(
format!("{}", AsLemmaSource(&fv)),
"quantity -> minimum 0 eur"
);
}
#[test]
fn as_lemma_source_boolean_default() {
let fv = DataValue::Definition {
base: Some(ParentType::Primitive {
primitive: PrimitiveKind::Boolean,
}),
constraints: Some(vec![(
TypeConstraintCommand::Default,
vec![boolean_arg(BooleanValue::True)],
)]),
value: None,
};
assert_eq!(format!("{}", AsLemmaSource(&fv)), "boolean -> default true");
}
#[test]
fn as_lemma_source_duration_default() {
let fv = DataValue::Definition {
base: Some(ParentType::Custom {
name: "duration".to_string(),
}),
constraints: Some(vec![(
TypeConstraintCommand::Default,
vec![duration_arg("40", "hours")],
)]),
value: None,
};
assert_eq!(
format!("{}", AsLemmaSource(&fv)),
"duration -> default 40 hours"
);
}
#[test]
fn as_lemma_source_named_type_default_quoted() {
let fv = DataValue::Definition {
base: Some(ParentType::Custom {
name: "filing_status_type".to_string(),
}),
constraints: Some(vec![(
TypeConstraintCommand::Default,
vec![text_arg("single")],
)]),
value: None,
};
assert_eq!(
format!("{}", AsLemmaSource(&fv)),
"filing_status_type -> default \"single\""
);
}
#[test]
fn as_lemma_source_help_escapes_quotes() {
let fv = DataValue::Definition {
base: Some(ParentType::Primitive {
primitive: PrimitiveKind::Text,
}),
constraints: Some(vec![(
TypeConstraintCommand::Help,
vec![text_arg("say \"hello\"")],
)]),
value: None,
};
assert_eq!(
format!("{}", AsLemmaSource(&fv)),
"text -> help \"say \\\"hello\\\"\""
);
}
fn unit_arg_expr(prefix: Decimal, factors: &[(&str, i32)]) -> UnitArg {
UnitArg::Expr(
prefix,
factors
.iter()
.map(|(quantity_ref, exp)| UnitFactor {
quantity_ref: (*quantity_ref).to_string(),
exp: *exp,
})
.collect(),
)
}
#[test]
fn unit_arg_display_metre_per_second() {
let arg = unit_arg_expr(Decimal::ONE, &[("metre", 1), ("second", -1)]);
assert_eq!(format!("{arg}"), "metre/second");
assert!(
!format!("{arg}").contains("second^-1"),
"must not print denominator as negative exponent"
);
}
#[test]
fn unit_arg_display_meter_per_second_squared() {
let arg = unit_arg_expr(Decimal::ONE, &[("meter", 1), ("second", -2)]);
assert_eq!(format!("{arg}"), "meter/second^2");
}
#[test]
fn unit_arg_display_kg_times_mps2() {
let arg = unit_arg_expr(Decimal::ONE, &[("kg", 1), ("mps2", 1)]);
assert_eq!(format!("{arg}"), "kg * mps2");
}
#[test]
fn unit_arg_display_numeric_prefix_metre_per_second() {
use std::str::FromStr;
let prefix = Decimal::from_str("3.6").expect("decimal");
let arg = unit_arg_expr(prefix, &[("metre", 1), ("second", -1)]);
assert_eq!(format!("{arg}"), "3.6 metre/second");
}
#[test]
fn unit_arg_display_metre_per_second_times_kg() {
let arg = unit_arg_expr(Decimal::ONE, &[("metre", 1), ("second", -1), ("kg", 1)]);
assert_eq!(format!("{arg}"), "metre/second * kg");
}
#[test]
fn unit_arg_display_kg_meter_per_second_squared() {
let arg = unit_arg_expr(Decimal::ONE, &[("kg", 1), ("meter", 1), ("second", -2)]);
assert_eq!(format!("{arg}"), "kg * meter/second^2");
}
}