lemma/evaluation/
request.rs1use crate::error::Error;
4use crate::planning::execution_plan::ExecutionPlan;
5use crate::planning::semantics::SemanticConversionTarget;
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, Default, PartialEq, Eq)]
10pub struct EvaluationRequest {
11 pub rule_result_units: HashMap<String, SemanticConversionTarget>,
13}
14
15impl EvaluationRequest {
16 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
90pub 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}