Skip to main content

lemma/evaluation/
request.rs

1//! Evaluation request options (rule-result unit conversion).
2
3use crate::error::Error;
4use crate::planning::execution_plan::ExecutionPlan;
5use crate::planning::semantics::SemanticConversionTarget;
6use std::collections::HashMap;
7
8/// Optional per-rule unit conversion for evaluation APIs.
9#[derive(Debug, Clone, Default, PartialEq, Eq)]
10pub struct EvaluationRequest {
11    /// Rule name → validated conversion target (quantity or ratio unit).
12    pub rule_result_units: HashMap<String, SemanticConversionTarget>,
13}
14
15impl EvaluationRequest {
16    /// Parse `rule:unit` strings and validate against the execution plan.
17    pub fn from_rule_conversion_strings(
18        strings: HashMap<String, String>,
19        plan: &ExecutionPlan,
20    ) -> Result<Self, Error> {
21        let mut rule_result_units = HashMap::new();
22        for (rule_name, unit_raw) in strings {
23            if rule_name.trim().is_empty() {
24                return Err(Error::request(
25                    "Rule name in conversion map cannot be empty",
26                    None::<String>,
27                ));
28            }
29            if rule_result_units.contains_key(&rule_name) {
30                return Err(Error::request(
31                    format!("Duplicate conversion for rule '{rule_name}'"),
32                    None::<String>,
33                ));
34            }
35            let unit = normalize_unit_name(&unit_raw)?;
36            if is_reserved_api_conversion_target(&unit) {
37                return Err(Error::request(
38                    format!(
39                        "API rule conversion supports quantity or ratio unit names only; '{}' is reserved. \
40                         Use in-rule `as` for other conversion targets.",
41                        unit
42                    ),
43                    None::<String>,
44                ));
45            }
46            let exec_rule = plan.get_rule(&rule_name).ok_or_else(|| {
47                Error::request(
48                    format!(
49                        "Rule '{}' not found in spec '{}'",
50                        rule_name, plan.spec_name
51                    ),
52                    None::<String>,
53                )
54            })?;
55            if !exec_rule.path.segments.is_empty() {
56                return Err(Error::request(
57                    format!(
58                        "Rule '{}' is not a top-level rule; API conversion applies only to top-level rules",
59                        rule_name
60                    ),
61                    None::<String>,
62                ));
63            }
64            let rule_type = &exec_rule.rule_type;
65            if rule_type.is_anonymous_quantity()
66                || (!rule_type.is_quantity() && !rule_type.is_ratio())
67            {
68                return Err(Error::request(
69                    format!(
70                        "Rule '{}' has result type '{}'; API conversion requires a quantity or ratio result type",
71                        rule_name,
72                        rule_type.name()
73                    ),
74                    None::<String>,
75                ));
76            }
77            let target = rule_type
78                .validate_rule_result_unit_conversion(&unit, &plan.unit_index, &plan.spec_name)
79                .map_err(|message| {
80                    Error::request(format!("Rule '{rule_name}': {message}"), None::<String>)
81                })?;
82            rule_result_units.insert(rule_name, target);
83        }
84        Ok(Self { rule_result_units })
85    }
86}
87
88/// Parse comma-separated or repeated `rule:unit` entries into a map of raw strings.
89pub fn parse_rule_result_conversion_strings(
90    comma_separated: &str,
91) -> Result<HashMap<String, String>, Error> {
92    let mut out = HashMap::new();
93    for segment in comma_separated.split(',') {
94        let segment = segment.trim();
95        if segment.is_empty() {
96            continue;
97        }
98        let (rule_name, unit) = segment.split_once(':').ok_or_else(|| {
99            Error::request(
100                format!(
101                    "Invalid conversion '{}'; expected 'rule:unit' (for example 'price:usd')",
102                    segment
103                ),
104                None::<String>,
105            )
106        })?;
107        let rule_name = rule_name.trim();
108        let unit = unit.trim();
109        if rule_name.is_empty() || unit.is_empty() {
110            return Err(Error::request(
111                format!(
112                    "Invalid conversion '{}'; rule name and unit cannot be empty",
113                    segment
114                ),
115                None::<String>,
116            ));
117        }
118        if out.contains_key(rule_name) {
119            return Err(Error::request(
120                format!("Duplicate conversion for rule '{rule_name}'"),
121                None::<String>,
122            ));
123        }
124        out.insert(rule_name.to_string(), unit.to_string());
125    }
126    Ok(out)
127}
128
129fn normalize_unit_name(raw: &str) -> Result<String, Error> {
130    let trimmed = raw.trim();
131    if trimmed.is_empty() {
132        return Err(Error::request("Unit name cannot be empty", None::<String>));
133    }
134    Ok(trimmed.to_lowercase())
135}
136
137fn is_reserved_api_conversion_target(unit: &str) -> bool {
138    unit == "number"
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::parsing::ast::DateTimeValue;
145    use crate::parsing::source::SourceType;
146    use crate::Engine;
147    use std::collections::HashMap;
148    use std::sync::Arc;
149
150    #[test]
151    fn parse_rule_result_conversion_strings_splits_pairs() {
152        let map = parse_rule_result_conversion_strings("price:usd,total:eur").unwrap();
153        assert_eq!(map.get("price").map(String::as_str), Some("usd"));
154        assert_eq!(map.get("total").map(String::as_str), Some("eur"));
155    }
156
157    #[test]
158    fn reserved_number_target_rejected() {
159        let mut engine = Engine::new();
160        engine
161            .load(
162                r#"spec money
163data price: quantity -> unit eur 1 -> unit usd 0.91 -> default 100 eur
164rule total: price"#,
165                SourceType::Path(Arc::new(std::path::PathBuf::from("t.lemma"))),
166            )
167            .unwrap();
168        let now = DateTimeValue::now();
169        let plan = engine.get_plan(None, "money", Some(&now)).unwrap().clone();
170        let err = EvaluationRequest::from_rule_conversion_strings(
171            HashMap::from([("total".to_string(), "number".to_string())]),
172            &plan,
173        )
174        .expect_err("number reserved");
175        assert!(err
176            .to_string()
177            .contains("quantity or ratio unit names only"));
178    }
179}