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