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(
161        first_canonical.clone(),
162        first_unit.clone(),
163    ))
164}
165
166fn value_kind_tag_for_type(spec: &TypeSpecification) -> &'static str {
167    match spec {
168        TypeSpecification::Boolean { .. } => "boolean",
169        TypeSpecification::Quantity { .. } => "quantity",
170        TypeSpecification::Number { .. } => "number",
171        TypeSpecification::NumberRange { .. }
172        | TypeSpecification::QuantityRange { .. }
173        | TypeSpecification::DateRange { .. }
174        | TypeSpecification::TimeRange { .. }
175        | TypeSpecification::RatioRange { .. } => "range",
176        TypeSpecification::Ratio { .. } => "ratio",
177        TypeSpecification::Text { .. } => "text",
178        TypeSpecification::Date { .. } => "date",
179        TypeSpecification::Time { .. } => "time",
180        TypeSpecification::Veto { .. } => "veto",
181        TypeSpecification::Undetermined => "undetermined",
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::computation::rational::{decimal_to_rational, rational_new, rational_one};
189    use crate::planning::semantics::{
190        primitive_number_arc, QuantityUnit, QuantityUnits, RatioUnit, RatioUnits, TypeExtends,
191    };
192
193    fn dummy_source() -> Source {
194        Source::new(
195            crate::parsing::source::SourceType::Volatile,
196            crate::planning::semantics::Span {
197                start: 0,
198                end: 0,
199                line: 1,
200                col: 1,
201            },
202        )
203    }
204
205    fn mass_quantity_type() -> Arc<LemmaType> {
206        Arc::new(LemmaType::new(
207            "Mass".to_string(),
208            TypeSpecification::Quantity {
209                minimum: None,
210                maximum: None,
211                decimals: None,
212                units: QuantityUnits::from(vec![
213                    QuantityUnit {
214                        name: "kilogram".to_string(),
215                        factor: rational_one(),
216                        derived_quantity_factors: Vec::new(),
217                        decomposition: crate::literals::BaseQuantityVector::new(),
218                        minimum: None,
219                        maximum: None,
220                        default_magnitude: None,
221                    },
222                    QuantityUnit {
223                        name: "gram".to_string(),
224                        factor: decimal_to_rational(Decimal::new(1, 3)).expect("factor"),
225                        derived_quantity_factors: Vec::new(),
226                        decomposition: crate::literals::BaseQuantityVector::new(),
227                        minimum: None,
228                        maximum: None,
229                        default_magnitude: None,
230                    },
231                ]),
232                traits: Vec::new(),
233                decomposition: None,
234                help: String::new(),
235            },
236            TypeExtends::Primitive,
237        ))
238    }
239
240    fn ratio_with_percent_type() -> Arc<LemmaType> {
241        Arc::new(LemmaType::new(
242            "Rate".to_string(),
243            TypeSpecification::Ratio {
244                minimum: None,
245                maximum: None,
246                decimals: None,
247                units: RatioUnits::from(vec![
248                    RatioUnit {
249                        name: "percent".to_string(),
250                        value: decimal_to_rational(Decimal::new(100, 0)).expect("factor"),
251                        minimum: None,
252                        maximum: None,
253                        default_magnitude: None,
254                    },
255                    RatioUnit {
256                        name: "fraction".to_string(),
257                        value: rational_one(),
258                        minimum: None,
259                        maximum: None,
260                        default_magnitude: None,
261                    },
262                ]),
263                help: String::new(),
264            },
265            TypeExtends::Primitive,
266        ))
267    }
268
269    #[test]
270    fn convenience_string_still_works() {
271        let ty = primitive_number_arc();
272        let lit = parse_data_value(
273            &DataValueInput::Convenience("42".to_string()),
274            ty,
275            &dummy_source(),
276        )
277        .unwrap();
278        assert!(matches!(lit.value, ValueKind::Number(_)));
279    }
280
281    #[test]
282    fn quantity_map_agreeing_units_canonicalize() {
283        let ty = mass_quantity_type();
284        let mut map = BTreeMap::new();
285        map.insert("kilogram".to_string(), "2".to_string());
286        map.insert("gram".to_string(), "2000".to_string());
287        let lit =
288            parse_data_value(&DataValueInput::QuantityMap(map), &ty, &dummy_source()).unwrap();
289        let ValueKind::Quantity(magnitude, signature) = &lit.value else {
290            panic!("expected quantity");
291        };
292        assert_eq!(magnitude, &rational_new(2, 1));
293        assert_eq!(signature.len(), 1);
294        assert_eq!(signature[0].1, 1);
295    }
296
297    #[test]
298    fn quantity_map_disagreeing_units_rejected() {
299        let ty = mass_quantity_type();
300        let mut map = BTreeMap::new();
301        map.insert("kilogram".to_string(), "2".to_string());
302        map.insert("gram".to_string(), "3000".to_string());
303        let err =
304            parse_data_value(&DataValueInput::QuantityMap(map), &ty, &dummy_source()).unwrap_err();
305        assert!(err.message().contains("disagree"));
306    }
307
308    #[test]
309    fn ratio_map_percent_and_fraction_agree() {
310        let ty = ratio_with_percent_type();
311        let mut map = BTreeMap::new();
312        map.insert("percent".to_string(), "10".to_string());
313        map.insert("fraction".to_string(), "0.1".to_string());
314        let lit = parse_data_value(&DataValueInput::RatioMap(map), &ty, &dummy_source()).unwrap();
315        let ValueKind::Ratio(canonical, unit) = &lit.value else {
316            panic!("expected ratio");
317        };
318        assert_eq!(
319            *canonical,
320            decimal_to_rational(Decimal::new(1, 1)).expect("canonical")
321        );
322        assert!(unit.is_some());
323    }
324}