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 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
88pub 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}