Skip to main content

golem_schema/schema/conversion/
quantity.rs

1// Copyright 2024-2026 Golem Cloud
2//
3// Licensed under the Golem Source License v1.1 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://license.golem.cloud/LICENSE
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Typed, unit-aware wrapper around the rich [`SchemaType::Quantity`] type.
16//!
17//! The schema of a Rust type is static — [`IntoSchema::register_in`] has no
18//! access to a value — so the accepted unit set is carried at the type level by
19//! a [`QuantityUnit`] marker type. This mirrors how validation enforces units
20//! from the schema's [`QuantitySpec`] rather than from each runtime value.
21
22use std::marker::PhantomData;
23
24use super::{
25    FromSchema, FromSchemaError, IntoSchema, SchemaBuilder, type_id_with_args, value_kind,
26};
27use crate::schema::metadata::TypeId;
28use crate::schema::schema_type::{QuantitySpec, QuantityValue, SchemaType};
29use crate::schema::schema_value::SchemaValue;
30
31/// Marker trait describing the unit constraints of a [`Quantity`].
32pub trait QuantityUnit: 'static {
33    /// Stable identifier of this unit, embedded into the [`Quantity`] type id.
34    fn type_id() -> TypeId;
35
36    /// Canonical base unit (e.g. `"kg"`, `"m"`, `"s"`, `"B"`).
37    fn base_unit() -> &'static str;
38
39    /// Suffixes accepted on input and rendered on output.
40    ///
41    /// If empty, only [`QuantityUnit::base_unit`] is accepted. If non-empty,
42    /// only the listed suffixes are accepted, so include the base unit here if
43    /// it should remain valid.
44    fn allowed_suffixes() -> &'static [&'static str] {
45        &[]
46    }
47}
48
49/// A fixed-point decimal value with a unit, constrained by the [`QuantityUnit`]
50/// marker `U`. Encodes to the rich [`SchemaValue::Quantity`].
51///
52/// `Clone`/`Debug`/`PartialEq`/`Eq` are implemented by hand so the marker type
53/// `U` itself does not need to implement them.
54pub struct Quantity<U: QuantityUnit> {
55    value: QuantityValue,
56    _unit: PhantomData<U>,
57}
58
59impl<U: QuantityUnit> Clone for Quantity<U> {
60    fn clone(&self) -> Self {
61        Self {
62            value: self.value.clone(),
63            _unit: PhantomData,
64        }
65    }
66}
67
68impl<U: QuantityUnit> std::fmt::Debug for Quantity<U> {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        f.debug_struct("Quantity")
71            .field("value", &self.value)
72            .finish()
73    }
74}
75
76impl<U: QuantityUnit> PartialEq for Quantity<U> {
77    fn eq(&self, other: &Self) -> bool {
78        self.value == other.value
79    }
80}
81
82impl<U: QuantityUnit> Eq for Quantity<U> {}
83
84impl<U: QuantityUnit> Quantity<U> {
85    /// Constructs a quantity, validating the unit against `U`.
86    pub fn new(
87        mantissa: i64,
88        scale: i32,
89        unit: impl Into<String>,
90    ) -> Result<Self, FromSchemaError> {
91        Self::from_quantity_value(QuantityValue {
92            mantissa,
93            scale,
94            unit: unit.into(),
95        })
96    }
97
98    /// Wraps a [`QuantityValue`], validating its unit against `U`.
99    pub fn from_quantity_value(value: QuantityValue) -> Result<Self, FromSchemaError> {
100        if !Self::unit_allowed(&value.unit) {
101            return Err(FromSchemaError::custom(format!(
102                "quantity unit `{}` is not allowed for `{}`",
103                value.unit,
104                U::type_id().as_str()
105            )));
106        }
107        Ok(Self {
108            value,
109            _unit: PhantomData,
110        })
111    }
112
113    pub fn as_quantity_value(&self) -> &QuantityValue {
114        &self.value
115    }
116
117    pub fn into_quantity_value(self) -> QuantityValue {
118        self.value
119    }
120
121    fn unit_allowed(unit: &str) -> bool {
122        let allowed = U::allowed_suffixes();
123        if allowed.is_empty() {
124            unit == U::base_unit()
125        } else {
126            allowed.contains(&unit)
127        }
128    }
129
130    fn spec() -> QuantitySpec {
131        QuantitySpec {
132            base_unit: U::base_unit().to_string(),
133            allowed_suffixes: U::allowed_suffixes()
134                .iter()
135                .map(|s| (*s).to_string())
136                .collect(),
137            min: None,
138            max: None,
139        }
140    }
141}
142
143impl<U: QuantityUnit> IntoSchema for Quantity<U> {
144    fn type_id() -> TypeId {
145        type_id_with_args("golem.schema.Quantity", &[U::type_id()])
146    }
147    fn register_in(_b: &mut SchemaBuilder) -> SchemaType {
148        SchemaType::quantity(Self::spec())
149    }
150    fn to_value(&self) -> SchemaValue {
151        SchemaValue::Quantity(self.value.clone())
152    }
153}
154
155impl<U: QuantityUnit> FromSchema for Quantity<U> {
156    fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
157        match v {
158            SchemaValue::Quantity(q) => Self::from_quantity_value(q.clone()),
159            other => Err(FromSchemaError::shape_mismatch(
160                "quantity",
161                value_kind(other),
162                "Quantity",
163            )),
164        }
165    }
166}