Skip to main content

lemma/planning/
data_input.rs

1use crate::planning::semantics::{
2    number_with_unit_to_value_kind, parse_value_from_string, parser_value_to_value_kind, LemmaType,
3    LiteralValue, Source, TypeSpecification, ValueKind,
4};
5use crate::Error;
6use rust_decimal::Decimal;
7use std::collections::BTreeMap;
8use std::str::FromStr;
9use std::sync::Arc;
10
11/// Typed data value from a client (CLI/WASM). JSON parsing stays outside [`parse_data_value`].
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum DataValueInput {
14    Convenience(String),
15    Boolean(bool),
16    QuantityMap(BTreeMap<String, String>),
17    RatioMap(BTreeMap<String, String>),
18}
19
20impl DataValueInput {
21    pub fn convenience(value: impl Into<String>) -> Self {
22        Self::Convenience(value.into())
23    }
24}
25
26pub fn parse_data_value(
27    input: &DataValueInput,
28    lemma_type: &Arc<LemmaType>,
29    source: &Source,
30) -> Result<LiteralValue, Error> {
31    let to_err = |msg: String| Error::validation(msg, Some(source.clone()), None::<String>);
32    let type_spec = &lemma_type.specifications;
33
34    let kind = match (input, type_spec) {
35        (DataValueInput::Convenience(s), _) => {
36            let parsed = parse_value_from_string(s, type_spec, source)?;
37            parser_value_to_value_kind(&parsed, type_spec).map_err(to_err)?
38        }
39        (DataValueInput::Boolean(b), TypeSpecification::Boolean { .. }) => ValueKind::Boolean(*b),
40        (DataValueInput::Boolean(_), _) => {
41            return Err(to_err(format!(
42                "boolean input is only valid for boolean data, not {}",
43                value_kind_tag_for_type(type_spec)
44            )));
45        }
46        (DataValueInput::QuantityMap(map), TypeSpecification::Quantity { .. }) => {
47            quantity_from_unit_map(map, lemma_type.as_ref()).map_err(to_err)?
48        }
49        (
50            DataValueInput::QuantityMap(map) | DataValueInput::RatioMap(map),
51            TypeSpecification::Ratio { .. },
52        ) => ratio_from_unit_map(map, lemma_type.as_ref()).map_err(to_err)?,
53        (DataValueInput::QuantityMap(_), _) => {
54            return Err(to_err(format!(
55                "quantity unit map is only valid for quantity data, not {}",
56                value_kind_tag_for_type(type_spec)
57            )));
58        }
59        (DataValueInput::RatioMap(_), _) => {
60            return Err(to_err(format!(
61                "ratio unit map is only valid for ratio data, not {}",
62                value_kind_tag_for_type(type_spec)
63            )));
64        }
65    };
66
67    Ok(LiteralValue {
68        value: kind,
69        lemma_type: Arc::clone(lemma_type),
70    })
71}
72
73fn quantity_from_unit_map(
74    map: &BTreeMap<String, String>,
75    lemma_type: &LemmaType,
76) -> Result<ValueKind, String> {
77    if map.is_empty() {
78        return Err("quantity input map must contain at least one unit key".to_string());
79    }
80    if lemma_type
81        .quantity_unit_names()
82        .is_none_or(|names| names.is_empty())
83    {
84        unreachable!("BUG: quantity type has no units at data input");
85    }
86
87    let mut kinds: Vec<ValueKind> = Vec::with_capacity(map.len());
88    for (unit_name, mag_str) in map {
89        let magnitude = Decimal::from_str(mag_str.trim())
90            .map_err(|error| format!("invalid decimal '{mag_str}': {error}"))?;
91        kinds.push(number_with_unit_to_value_kind(
92            magnitude, unit_name, lemma_type,
93        )?);
94    }
95
96    let first = kinds.first().expect("BUG: map non-empty");
97    let ValueKind::Quantity(first_magnitude, first_signature) = first else {
98        return Err("expected quantity value".to_string());
99    };
100    if first_signature.len() != 1 || first_signature[0].1 != 1 {
101        return Err(
102            "quantity map produced a compound signature; use a convenience string instead"
103                .to_string(),
104        );
105    }
106    for kind in kinds.iter().skip(1) {
107        let ValueKind::Quantity(magnitude, signature) = kind else {
108            return Err("expected quantity value".to_string());
109        };
110        if signature.len() != 1 || signature[0].1 != 1 {
111            return Err(
112                "quantity map produced a compound signature; use a convenience string instead"
113                    .to_string(),
114            );
115        }
116        if magnitude != first_magnitude {
117            return Err(
118                "quantity unit map values disagree when converted to a common basis".to_string(),
119            );
120        }
121    }
122    Ok(first.clone())
123}
124
125fn ratio_from_unit_map(
126    map: &BTreeMap<String, String>,
127    lemma_type: &LemmaType,
128) -> Result<ValueKind, String> {
129    if map.is_empty() {
130        return Err("ratio input map must contain at least one unit key".to_string());
131    }
132    match &lemma_type.specifications {
133        TypeSpecification::Ratio { units, .. } if !units.is_empty() => {}
134        _ => unreachable!("BUG: ratio type has no units at data input"),
135    }
136
137    let mut kinds: Vec<ValueKind> = Vec::with_capacity(map.len());
138    for (unit_name, mag_str) in map {
139        let magnitude = Decimal::from_str(mag_str.trim())
140            .map_err(|error| format!("invalid decimal '{mag_str}': {error}"))?;
141        kinds.push(number_with_unit_to_value_kind(
142            magnitude, unit_name, lemma_type,
143        )?);
144    }
145
146    let first = kinds.first().expect("BUG: map non-empty");
147    let ValueKind::Ratio(first_canonical, first_unit) = first else {
148        return Err("expected ratio value".to_string());
149    };
150    for kind in kinds.iter().skip(1) {
151        let ValueKind::Ratio(canonical, _) = kind else {
152            return Err("expected ratio value".to_string());
153        };
154        if canonical != first_canonical {
155            return Err(
156                "ratio unit map values disagree when converted to a common basis".to_string(),
157            );
158        }
159    }
160    Ok(ValueKind::Ratio(*first_canonical, first_unit.clone()))
161}
162
163fn value_kind_tag_for_type(spec: &TypeSpecification) -> &'static str {
164    match spec {
165        TypeSpecification::Boolean { .. } => "boolean",
166        TypeSpecification::Quantity { .. } => "quantity",
167        TypeSpecification::Number { .. } => "number",
168        TypeSpecification::NumberRange { .. }
169        | TypeSpecification::QuantityRange { .. }
170        | TypeSpecification::DateRange { .. }
171        | TypeSpecification::TimeRange { .. }
172        | TypeSpecification::RatioRange { .. } => "range",
173        TypeSpecification::Ratio { .. } => "ratio",
174        TypeSpecification::Text { .. } => "text",
175        TypeSpecification::Date { .. } => "date",
176        TypeSpecification::Time { .. } => "time",
177        TypeSpecification::Veto { .. } => "veto",
178        TypeSpecification::Undetermined => "undetermined",
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::computation::rational::{decimal_to_rational, rational_one};
186    use crate::planning::semantics::{
187        primitive_number_arc, QuantityUnit, QuantityUnits, RatioUnit, RatioUnits, TypeExtends,
188    };
189
190    fn dummy_source() -> Source {
191        Source::new(
192            crate::parsing::source::SourceType::Volatile,
193            crate::planning::semantics::Span {
194                start: 0,
195                end: 0,
196                line: 1,
197                col: 1,
198            },
199        )
200    }
201
202    fn mass_quantity_type() -> Arc<LemmaType> {
203        Arc::new(LemmaType::new(
204            "Mass".to_string(),
205            TypeSpecification::Quantity {
206                minimum: None,
207                maximum: None,
208                decimals: None,
209                units: QuantityUnits::from(vec![
210                    QuantityUnit {
211                        name: "kilogram".to_string(),
212                        factor: rational_one(),
213                        derived_quantity_factors: Vec::new(),
214                        decomposition: crate::literals::BaseQuantityVector::new(),
215                        minimum: None,
216                        maximum: None,
217                        default_magnitude: None,
218                    },
219                    QuantityUnit {
220                        name: "gram".to_string(),
221                        factor: decimal_to_rational(Decimal::new(1, 3)).expect("factor"),
222                        derived_quantity_factors: Vec::new(),
223                        decomposition: crate::literals::BaseQuantityVector::new(),
224                        minimum: None,
225                        maximum: None,
226                        default_magnitude: None,
227                    },
228                ]),
229                traits: Vec::new(),
230                decomposition: None,
231                help: String::new(),
232            },
233            TypeExtends::Primitive,
234        ))
235    }
236
237    fn ratio_with_percent_type() -> Arc<LemmaType> {
238        Arc::new(LemmaType::new(
239            "Rate".to_string(),
240            TypeSpecification::Ratio {
241                minimum: None,
242                maximum: None,
243                decimals: None,
244                units: RatioUnits::from(vec![
245                    RatioUnit {
246                        name: "percent".to_string(),
247                        value: decimal_to_rational(Decimal::new(100, 0)).expect("factor"),
248                        minimum: None,
249                        maximum: None,
250                        default_magnitude: None,
251                    },
252                    RatioUnit {
253                        name: "fraction".to_string(),
254                        value: rational_one(),
255                        minimum: None,
256                        maximum: None,
257                        default_magnitude: None,
258                    },
259                ]),
260                help: String::new(),
261            },
262            TypeExtends::Primitive,
263        ))
264    }
265
266    #[test]
267    fn convenience_string_still_works() {
268        let ty = primitive_number_arc();
269        let lit = parse_data_value(
270            &DataValueInput::Convenience("42".to_string()),
271            ty,
272            &dummy_source(),
273        )
274        .unwrap();
275        assert!(matches!(lit.value, ValueKind::Number(_)));
276    }
277
278    #[test]
279    fn quantity_map_agreeing_units_canonicalize() {
280        let ty = mass_quantity_type();
281        let mut map = BTreeMap::new();
282        map.insert("kilogram".to_string(), "2".to_string());
283        map.insert("gram".to_string(), "2000".to_string());
284        let lit =
285            parse_data_value(&DataValueInput::QuantityMap(map), &ty, &dummy_source()).unwrap();
286        let ValueKind::Quantity(magnitude, signature) = &lit.value else {
287            panic!("expected quantity");
288        };
289        assert_eq!(*magnitude, rational_one() + rational_one());
290        assert_eq!(signature.len(), 1);
291        assert_eq!(signature[0].1, 1);
292    }
293
294    #[test]
295    fn quantity_map_disagreeing_units_rejected() {
296        let ty = mass_quantity_type();
297        let mut map = BTreeMap::new();
298        map.insert("kilogram".to_string(), "2".to_string());
299        map.insert("gram".to_string(), "3000".to_string());
300        let err =
301            parse_data_value(&DataValueInput::QuantityMap(map), &ty, &dummy_source()).unwrap_err();
302        assert!(err.message().contains("disagree"));
303    }
304
305    #[test]
306    fn ratio_map_percent_and_fraction_agree() {
307        let ty = ratio_with_percent_type();
308        let mut map = BTreeMap::new();
309        map.insert("percent".to_string(), "10".to_string());
310        map.insert("fraction".to_string(), "0.1".to_string());
311        let lit = parse_data_value(&DataValueInput::RatioMap(map), &ty, &dummy_source()).unwrap();
312        let ValueKind::Ratio(canonical, unit) = &lit.value else {
313            panic!("expected ratio");
314        };
315        assert_eq!(
316            *canonical,
317            decimal_to_rational(Decimal::new(1, 1)).expect("canonical")
318        );
319        assert!(unit.is_some());
320    }
321}