1use crate::evaluation::evaluation_trace::EvaluationTrace;
2use crate::evaluation::operations::{OperationResult, VetoType};
3use crate::parsing::ast::DateTimeValue;
4use crate::planning::semantics::{
5 range_element_type_specification, DataPath, LemmaType, RulePath, SemanticDateTime,
6 SemanticTime, Source, TypeSpecification, ValueKind,
7};
8use indexmap::IndexMap;
9use serde::Serialize;
10use std::collections::{BTreeMap, BTreeSet};
11use std::sync::Arc;
12
13#[derive(Debug, Clone, Serialize)]
16pub struct EvaluatedRule {
17 pub name: String,
18 pub path: RulePath,
19 pub source_location: Source,
20 pub rule_type: LemmaType,
21}
22
23#[derive(Debug, Clone, Serialize)]
25pub struct DataGroup {
26 pub data_path: String,
27 pub referencing_data_name: String,
28 pub data: Vec<crate::planning::semantics::Data>,
29}
30
31#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
33pub struct CalendarResult {
34 pub value: String,
35 pub unit: String,
36}
37
38#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)]
40pub struct RuleResultPayload {
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub quantity: Option<BTreeMap<String, String>>,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub ratio: Option<BTreeMap<String, String>>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub number: Option<String>,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 pub boolean: Option<bool>,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub text: Option<String>,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub date: Option<SemanticDateTime>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub time: Option<SemanticTime>,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub calendar: Option<CalendarResult>,
57}
58
59#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
61pub struct RangeResult {
62 pub from: RuleResultPayload,
63 pub to: RuleResultPayload,
64}
65
66#[derive(Debug, Clone, Serialize)]
68pub struct Response {
69 #[serde(rename = "spec")]
70 pub spec_name: String,
71 pub effective: String,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub spec_hash: Option<String>,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub spec_effective_from: Option<DateTimeValue>,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub spec_effective_to: Option<DateTimeValue>,
78 #[serde(skip_serializing_if = "Vec::is_empty")]
79 pub data: Vec<DataGroup>,
80 pub results: IndexMap<String, RuleResult>,
81}
82
83#[derive(Debug, Clone, Serialize)]
85pub struct RuleResult {
86 #[serde(skip)]
87 pub rule: EvaluatedRule,
88 #[serde(skip)]
89 pub veto_detail: Option<VetoType>,
90
91 pub vetoed: bool,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 pub display: Option<String>,
94 #[serde(skip_serializing_if = "Option::is_none")]
95 pub veto_reason: Option<String>,
96 pub rule_type: String,
97
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub quantity: Option<BTreeMap<String, String>>,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 pub ratio: Option<BTreeMap<String, String>>,
102 #[serde(skip_serializing_if = "Option::is_none")]
103 pub number: Option<String>,
104 #[serde(skip_serializing_if = "Option::is_none")]
105 pub boolean: Option<bool>,
106 #[serde(skip_serializing_if = "Option::is_none")]
107 pub text: Option<String>,
108 #[serde(skip_serializing_if = "Option::is_none")]
109 pub date: Option<SemanticDateTime>,
110 #[serde(skip_serializing_if = "Option::is_none")]
111 pub time: Option<SemanticTime>,
112 #[serde(skip_serializing_if = "Option::is_none")]
113 pub calendar: Option<CalendarResult>,
114 #[serde(skip_serializing_if = "Option::is_none")]
115 pub range: Option<RangeResult>,
116
117 #[serde(skip_serializing_if = "Option::is_none")]
118 #[serde(rename = "explanation")]
119 pub trace: Option<EvaluationTrace>,
120}
121
122impl RuleResult {
123 pub fn from_operation_result(
129 rule: EvaluatedRule,
130 operation_result: OperationResult,
131 rule_type: &LemmaType,
132 expression_units: &std::collections::HashMap<String, Arc<LemmaType>>,
133 trace: Option<EvaluationTrace>,
134 ) -> Self {
135 let rule_type_name = rule_type.name().to_string();
136 match operation_result {
137 OperationResult::Veto(veto) => Self {
138 rule,
139 veto_detail: Some(veto.clone()),
140 vetoed: true,
141 display: None,
142 veto_reason: Some(veto.to_string()),
143 rule_type: rule_type_name,
144 quantity: None,
145 ratio: None,
146 number: None,
147 boolean: None,
148 text: None,
149 date: None,
150 time: None,
151 calendar: None,
152 range: None,
153 trace,
154 },
155 OperationResult::Value(literal) => {
156 let mut result = Self {
157 rule,
158 veto_detail: None,
159 vetoed: false,
160 display: None,
161 veto_reason: None,
162 rule_type: rule_type_name,
163 quantity: None,
164 ratio: None,
165 number: None,
166 boolean: None,
167 text: None,
168 date: None,
169 time: None,
170 calendar: None,
171 range: None,
172 trace,
173 };
174 match &literal.value {
175 ValueKind::Range(from, to) => {
176 let endpoint_type = element_type_from_range_rule(rule_type)
177 .unwrap_or_else(|| rule_type.clone());
178 result.range = Some(RangeResult {
179 from: materialize_payload(
180 from,
181 &endpoint_materialization_type(from, &endpoint_type),
182 expression_units,
183 ),
184 to: materialize_payload(
185 to,
186 &endpoint_materialization_type(to, &endpoint_type),
187 expression_units,
188 ),
189 });
190 result.display = Some(literal.to_string());
191 }
192 _ => {
193 let payload =
194 materialize_payload(literal.as_ref(), rule_type, expression_units);
195 result.quantity = payload.quantity;
196 result.ratio = payload.ratio;
197 result.number = payload.number;
198 result.boolean = payload.boolean;
199 result.text = payload.text;
200 result.date = payload.date;
201 result.time = payload.time;
202 result.calendar = payload.calendar;
203 result.display = Some(literal.to_string());
204 }
205 }
206 result
207 }
208 }
209 }
210}
211
212fn element_type_from_range_rule(rule_type: &LemmaType) -> Option<LemmaType> {
213 range_element_type_specification(&rule_type.specifications).map(LemmaType::primitive)
214}
215
216fn endpoint_materialization_type(
217 endpoint: &crate::planning::semantics::LiteralValue,
218 range_element_type: &LemmaType,
219) -> LemmaType {
220 if endpoint.lemma_type.quantity_unit_names().is_some() {
221 endpoint.lemma_type.as_ref().clone()
222 } else {
223 range_element_type.clone()
224 }
225}
226
227fn materialize_payload(
228 literal: &crate::planning::semantics::LiteralValue,
229 result_type: &LemmaType,
230 _expression_units: &std::collections::HashMap<String, Arc<LemmaType>>,
231) -> RuleResultPayload {
232 match &literal.value {
233 ValueKind::Quantity(rational, sig) if literal.lemma_type.is_calendar_like() => {
234 let unit =
235 crate::planning::semantics::semantic_calendar_unit_from_quantity_signature(sig);
236 RuleResultPayload {
237 calendar: Some(CalendarResult {
238 value: rational_to_wire_string(rational),
239 unit: unit.to_string(),
240 }),
241 ..RuleResultPayload::default()
242 }
243 }
244 ValueKind::Quantity(_, _) => RuleResultPayload {
245 quantity: Some(quantity_to_unit_map(literal, result_type)),
246 ..RuleResultPayload::default()
247 },
248 ValueKind::Ratio(_, _) => RuleResultPayload {
249 ratio: Some(ratio_to_unit_map(literal, result_type)),
250 ..RuleResultPayload::default()
251 },
252 ValueKind::Number(rational) => RuleResultPayload {
253 number: Some(rational_to_wire_string(rational)),
254 ..RuleResultPayload::default()
255 },
256 ValueKind::Boolean(b) => RuleResultPayload {
257 boolean: Some(*b),
258 ..RuleResultPayload::default()
259 },
260 ValueKind::Text(s) => RuleResultPayload {
261 text: Some(s.clone()),
262 ..RuleResultPayload::default()
263 },
264 ValueKind::Date(d) => RuleResultPayload {
265 date: Some(d.clone()),
266 ..RuleResultPayload::default()
267 },
268 ValueKind::Time(t) => RuleResultPayload {
269 time: Some(t.clone()),
270 ..RuleResultPayload::default()
271 },
272 ValueKind::Range(_, _) => {
273 panic!("BUG: range payload must be built at RuleResult level, not RuleResultPayload")
274 }
275 }
276}
277
278fn rational_to_wire_string(rational: &crate::computation::rational::RationalInteger) -> String {
279 crate::literals::rational_to_serialized_str(rational)
280 .expect("BUG: rule result magnitude must serialize to decimal string")
281}
282
283fn quantity_to_unit_map(
284 literal: &crate::planning::semantics::LiteralValue,
285 result_type: &LemmaType,
286) -> BTreeMap<String, String> {
287 use crate::computation::rational::checked_div;
288
289 let unit_names = result_type
290 .quantity_unit_names()
291 .expect("BUG: rule result quantity must have declared units");
292 let ValueKind::Quantity(magnitude, _signature) = &literal.value else {
293 panic!("BUG: quantity_to_unit_map called with non-quantity value");
294 };
295 let mut map = BTreeMap::new();
296 for unit_name in unit_names {
297 let to_factor = result_type.quantity_unit_factor(unit_name);
298 let converted = checked_div(magnitude, to_factor).unwrap_or_else(|failure| {
299 panic!(
300 "BUG: quantity unit conversion to '{}' failed at rule result materialization: {}",
301 unit_name, failure
302 )
303 });
304 map.insert(unit_name.to_string(), rational_to_wire_string(&converted));
305 }
306 map
307}
308
309fn ratio_to_unit_map(
310 literal: &crate::planning::semantics::LiteralValue,
311 result_type: &LemmaType,
312) -> BTreeMap<String, String> {
313 use crate::computation::rational::checked_mul;
314
315 let units = match &result_type.specifications {
316 TypeSpecification::Ratio { units, .. } => units,
317 TypeSpecification::RatioRange { .. } => {
318 let element = range_element_type_specification(&result_type.specifications)
319 .expect("BUG: ratio range rule type must have ratio element specification");
320 let TypeSpecification::Ratio { units, .. } = element else {
321 panic!("BUG: ratio range element spec must be Ratio");
322 };
323 return ratio_to_unit_map(
324 literal,
325 &LemmaType::primitive(TypeSpecification::Ratio {
326 minimum: None,
327 maximum: None,
328 decimals: None,
329 units,
330 help: String::new(),
331 }),
332 );
333 }
334 _ => {
335 panic!(
336 "BUG: ratio_to_unit_map called with non-ratio type {}",
337 result_type.name()
338 );
339 }
340 };
341 let ValueKind::Ratio(canonical, _) = &literal.value else {
342 panic!("BUG: ratio_to_unit_map called with non-ratio value");
343 };
344 if units.is_empty() {
345 panic!(
346 "BUG: rule result ratio type '{}' must have declared units",
347 result_type.name()
348 );
349 }
350 let mut map = BTreeMap::new();
351 for unit in units.iter() {
352 let display = checked_mul(canonical, &unit.value).unwrap_or_else(|failure| {
353 panic!(
354 "BUG: ratio unit conversion to '{}' failed at rule result materialization: {}",
355 unit.name, failure
356 )
357 });
358 map.insert(unit.name.clone(), rational_to_wire_string(&display));
359 }
360 map
361}
362
363impl Response {
364 pub fn get(&self, rule_name: &str) -> Result<&RuleResult, crate::error::Error> {
368 self.results
369 .get(rule_name)
370 .ok_or_else(|| crate::error::Error::rule_not_found(rule_name, None::<String>))
371 }
372
373 pub fn add_result(&mut self, result: RuleResult) {
374 self.results.insert(result.rule.name.clone(), result);
375 }
376
377 pub fn filter_rules(&mut self, rule_names: &[String]) {
378 self.results.retain(|name, _| rule_names.contains(name));
379 }
380
381 #[must_use]
383 pub fn missing_data(&self) -> BTreeSet<DataPath> {
384 self.missing_data_ordered().into_iter().collect()
385 }
386
387 #[must_use]
390 pub fn missing_data_ordered(&self) -> Vec<DataPath> {
391 let mut seen = std::collections::HashSet::new();
392 let mut out = Vec::new();
393 for rr in self.results.values() {
394 if let Some(VetoType::MissingData { data }) = &rr.veto_detail {
395 if seen.insert(data.clone()) {
396 out.push(data.clone());
397 }
398 }
399 }
400 out
401 }
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407 use crate::planning::semantics::{
408 primitive_boolean, primitive_number, BaseQuantityVector, LemmaType, LiteralValue,
409 QuantityUnit, QuantityUnits, RatioUnit, RatioUnits, RulePath, Span, TypeExtends,
410 TypeSpecification,
411 };
412 use rust_decimal::Decimal;
413 use std::collections::HashMap;
414 use std::sync::Arc;
415
416 fn dummy_source() -> Source {
417 Source::new(
418 crate::parsing::source::SourceType::Volatile,
419 Span {
420 start: 0,
421 end: 0,
422 line: 1,
423 col: 1,
424 },
425 )
426 }
427
428 fn dummy_evaluated_rule(name: &str) -> EvaluatedRule {
429 EvaluatedRule {
430 name: name.to_string(),
431 path: RulePath::new(vec![], name.to_string()),
432 source_location: dummy_source(),
433 rule_type: primitive_number().clone(),
434 }
435 }
436
437 #[test]
438 fn test_response_serialization() {
439 let mut results = IndexMap::new();
440 let expression_units = std::collections::HashMap::new();
441 results.insert(
442 "test_rule".to_string(),
443 RuleResult::from_operation_result(
444 dummy_evaluated_rule("test_rule"),
445 OperationResult::Value(Box::new(LiteralValue::number_from_decimal(Decimal::from(
446 42,
447 )))),
448 primitive_number(),
449 &expression_units,
450 None,
451 ),
452 );
453 let response = Response {
454 spec_name: "test_spec".to_string(),
455 effective: "2026-01-01".to_string(),
456 spec_hash: None,
457 spec_effective_from: None,
458 spec_effective_to: None,
459 data: vec![],
460 results,
461 };
462
463 let json = serde_json::to_string(&response).unwrap();
464 assert!(json.contains("test_spec"));
465 assert!(json.contains("test_rule"));
466 assert!(json.contains("\"number\":\"42\""));
467 assert!(!json.contains("lemma_type"));
468 }
469
470 #[test]
471 fn response_number_json_never_uses_fraction_notation() {
472 use crate::computation::rational::{commit_rational_to_decimal, decimal_to_rational};
473
474 let rational = decimal_to_rational(Decimal::new(1, 1) / Decimal::new(3, 1)).unwrap();
475 let wire_number = commit_rational_to_decimal(&rational).unwrap().to_string();
476 let mut results = IndexMap::new();
477 results.insert(
478 "third".to_string(),
479 RuleResult::from_operation_result(
480 dummy_evaluated_rule("third"),
481 OperationResult::Value(Box::new(LiteralValue::number_from_decimal(
482 commit_rational_to_decimal(&rational).unwrap(),
483 ))),
484 primitive_number(),
485 &std::collections::HashMap::new(),
486 None,
487 ),
488 );
489 if let Some(rule) = results.get_mut("third") {
491 rule.number = Some(wire_number.clone());
492 rule.display = Some(wire_number);
493 }
494
495 let response = Response {
496 spec_name: "test".to_string(),
497 effective: "test".to_string(),
498 spec_hash: None,
499 spec_effective_from: None,
500 spec_effective_to: None,
501 data: vec![],
502 results,
503 };
504
505 let json: serde_json::Value =
506 serde_json::from_str(&serde_json::to_string(&response).unwrap()).unwrap();
507 let number = json["results"]["third"]["number"]
508 .as_str()
509 .expect("number must be a JSON string");
510 assert!(
511 !number.contains('/'),
512 "wire number must not use fraction notation, got {number}"
513 );
514 }
515
516 #[test]
517 fn test_response_filter_rules() {
518 let mut results = IndexMap::new();
519 let expression_units = std::collections::HashMap::new();
520 results.insert(
521 "rule1".to_string(),
522 RuleResult::from_operation_result(
523 dummy_evaluated_rule("rule1"),
524 OperationResult::Value(Box::new(LiteralValue::from_bool(true))),
525 primitive_boolean(),
526 &expression_units,
527 None,
528 ),
529 );
530 results.insert(
531 "rule2".to_string(),
532 RuleResult::from_operation_result(
533 dummy_evaluated_rule("rule2"),
534 OperationResult::Value(Box::new(LiteralValue::from_bool(false))),
535 primitive_boolean(),
536 &expression_units,
537 None,
538 ),
539 );
540 let mut response = Response {
541 spec_name: "test_spec".to_string(),
542 effective: "2026-01-01".to_string(),
543 spec_hash: None,
544 spec_effective_from: None,
545 spec_effective_to: None,
546 data: vec![],
547 results,
548 };
549
550 response.filter_rules(&["rule1".to_string()]);
551
552 assert_eq!(response.results.len(), 1);
553 assert_eq!(response.results.values().next().unwrap().rule.name, "rule1");
554 }
555
556 #[test]
557 fn test_rule_result_veto() {
558 let expression_units = std::collections::HashMap::new();
559 let missing = RuleResult::from_operation_result(
560 dummy_evaluated_rule("rule3"),
561 OperationResult::Veto(VetoType::MissingData {
562 data: DataPath::new(vec![], "data1".to_string()),
563 }),
564 &LemmaType::veto_type(),
565 &expression_units,
566 None,
567 );
568 assert!(missing.vetoed);
569 assert!(missing.veto_reason.as_ref().unwrap().contains("data1"));
570
571 let veto = RuleResult::from_operation_result(
572 dummy_evaluated_rule("rule4"),
573 OperationResult::Veto(VetoType::UserDefined {
574 message: Some("Vetoed".to_string()),
575 }),
576 &LemmaType::veto_type(),
577 &expression_units,
578 None,
579 );
580 assert_eq!(veto.veto_reason.as_deref(), Some("Vetoed"));
581 }
582
583 fn test_money_type() -> LemmaType {
584 LemmaType::new(
585 "money".to_string(),
586 TypeSpecification::Quantity {
587 minimum: None,
588 maximum: None,
589 decimals: Some(2),
590 units: QuantityUnits::from(vec![
591 QuantityUnit {
592 name: "eur".to_string(),
593 factor: crate::computation::rational::rational_one(),
594 derived_quantity_factors: Vec::new(),
595 decomposition: BaseQuantityVector::new(),
596 minimum: None,
597 maximum: None,
598 default_magnitude: None,
599 },
600 QuantityUnit {
601 name: "usd".to_string(),
602 factor: crate::computation::rational::decimal_to_rational(Decimal::new(
603 91, 2,
604 ))
605 .expect("factor"),
606 derived_quantity_factors: Vec::new(),
607 decomposition: BaseQuantityVector::new(),
608 minimum: None,
609 maximum: None,
610 default_magnitude: None,
611 },
612 ]),
613 traits: Vec::new(),
614 decomposition: Some(BaseQuantityVector::new()),
615 help: String::new(),
616 },
617 TypeExtends::Primitive,
618 )
619 }
620
621 #[test]
622 fn quantity_materialization_uses_rule_type_when_expression_index_empty() {
623 let money = test_money_type();
624 let ten_usd = LiteralValue {
625 value: ValueKind::Quantity(
626 crate::computation::rational::checked_mul(
627 &crate::computation::rational::decimal_to_rational(Decimal::from(10))
628 .expect("ten"),
629 &crate::computation::rational::decimal_to_rational(Decimal::new(91, 2))
630 .expect("usd factor"),
631 )
632 .expect("canonical usd"),
633 vec![("usd".to_string(), 1)],
634 ),
635 lemma_type: Arc::new(money.clone()),
636 };
637 let expression_units = HashMap::new();
638 let result = RuleResult::from_operation_result(
639 dummy_evaluated_rule("total"),
640 OperationResult::Value(Box::new(ten_usd)),
641 &money,
642 &expression_units,
643 None,
644 );
645 let quantity = result.quantity.expect("quantity map");
646 assert_eq!(quantity.get("usd"), Some(&"10".to_string()));
647 assert!(quantity.contains_key("eur"));
648 }
649
650 #[test]
651 fn test_quantity_materialization_multi_unit() {
652 let money = test_money_type();
653 let expression_units = HashMap::new();
654 let ten_eur = LiteralValue {
655 value: ValueKind::Quantity(
656 crate::computation::rational::decimal_to_rational(Decimal::from(10)).expect("ten"),
657 vec![],
658 ),
659 lemma_type: Arc::new(money.clone()),
660 };
661 let result = RuleResult::from_operation_result(
662 dummy_evaluated_rule("total"),
663 OperationResult::Value(Box::new(ten_eur)),
664 &money,
665 &expression_units,
666 None,
667 );
668 let quantity = result.quantity.expect("quantity map");
669 assert_eq!(quantity.get("eur"), Some(&"10".to_string()));
670 assert!(quantity.contains_key("usd"));
671 assert!(quantity["usd"].starts_with("10.9"));
672 }
673
674 #[test]
675 fn test_ratio_materialization_multi_unit() {
676 let ratio_type = LemmaType::new(
677 "rate".to_string(),
678 TypeSpecification::Ratio {
679 minimum: None,
680 maximum: None,
681 decimals: None,
682 units: RatioUnits::from(vec![
683 RatioUnit {
684 name: "percent".to_string(),
685 value: crate::computation::rational::decimal_to_rational(Decimal::from(
686 100,
687 ))
688 .expect("percent"),
689 minimum: None,
690 maximum: None,
691 default_magnitude: None,
692 },
693 RatioUnit {
694 name: "basis_points".to_string(),
695 value: crate::computation::rational::decimal_to_rational(Decimal::from(
696 10_000,
697 ))
698 .expect("bp"),
699 minimum: None,
700 maximum: None,
701 default_magnitude: None,
702 },
703 ]),
704 help: String::new(),
705 },
706 TypeExtends::Primitive,
707 );
708 let expression_units = HashMap::new();
709 let half = crate::computation::rational::RationalInteger::new(1, 2);
710 let lit = LiteralValue {
711 value: ValueKind::Ratio(half, Some("percent".to_string())),
712 lemma_type: Arc::new(ratio_type.clone()),
713 };
714 let result = RuleResult::from_operation_result(
715 dummy_evaluated_rule("rate_out"),
716 OperationResult::Value(Box::new(lit)),
717 &ratio_type,
718 &expression_units,
719 None,
720 );
721 let ratio = result.ratio.expect("ratio map");
722 assert_eq!(ratio.get("percent"), Some(&"50".to_string()));
723 assert_eq!(ratio.get("basis_points"), Some(&"5000".to_string()));
724 }
725
726 #[test]
727 fn test_quantity_materialization_cross_spec_import() {
728 use crate::parsing::source::SourceType;
729 use crate::Engine;
730
731 let mut engine = Engine::new();
732 engine
733 .load(
734 r#"
735spec consumer 2025-01-01
736uses d: dep 2025-10-01
737rule out: d.doubled
738
739spec dep 2025-01-01
740uses c: child 2025-06-01
741data money: c.money
742data p: 5 usd
743rule doubled: p * 2
744
745spec child 2025-01-01
746data money: quantity
747 -> unit eur 1.00
748 -> decimals 2
749
750spec child 2025-06-01
751data money: quantity
752 -> unit eur 1.00
753 -> unit usd 0.91
754 -> decimals 2
755"#,
756 SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("t.lemma"))),
757 )
758 .expect("load");
759 let effective = crate::literals::DateTimeValue {
760 year: 2025,
761 month: 3,
762 day: 1,
763 hour: 0,
764 minute: 0,
765 second: 0,
766 microsecond: 0,
767 timezone: None,
768 };
769 let response = engine
770 .run(
771 None,
772 "consumer",
773 Some(&effective),
774 std::collections::HashMap::new(),
775 false,
776 )
777 .expect("run");
778 let out = response.results.get("out").expect("out rule");
779 assert!(!out.vetoed);
780 let quantity = out.quantity.as_ref().expect("quantity map");
781 assert!(quantity.contains_key("usd"));
782 assert!(quantity.contains_key("eur"));
783 }
784}