1use crate::evaluation::Evaluator;
2use crate::parsing::ast::LemmaDoc;
3use crate::registry::Registry;
4use crate::{parse, LemmaError, LemmaResult, ResourceLimits, Response};
5use std::collections::HashMap;
6use std::sync::Arc;
7
8pub struct Engine {
18 execution_plans: HashMap<String, crate::planning::ExecutionPlan>,
19 documents: HashMap<String, LemmaDoc>,
20 sources: HashMap<String, String>,
21 evaluator: Evaluator,
22 limits: ResourceLimits,
23 registry: Option<Arc<dyn Registry>>,
24}
25
26impl Default for Engine {
27 fn default() -> Self {
28 Self {
29 execution_plans: HashMap::new(),
30 documents: HashMap::new(),
31 sources: HashMap::new(),
32 evaluator: Evaluator,
33 limits: ResourceLimits::default(),
34 registry: Self::default_registry(),
35 }
36 }
37}
38
39impl Engine {
40 pub fn new() -> Self {
41 Self::default()
42 }
43
44 fn default_registry() -> Option<Arc<dyn Registry>> {
52 #[cfg(feature = "registry")]
53 {
54 Some(Arc::new(crate::registry::LemmaBase::new()))
55 }
56 #[cfg(not(feature = "registry"))]
57 {
58 None
59 }
60 }
61
62 pub fn with_limits(limits: ResourceLimits) -> Self {
66 Self {
67 execution_plans: HashMap::new(),
68 documents: HashMap::new(),
69 sources: HashMap::new(),
70 evaluator: Evaluator,
71 limits,
72 registry: Self::default_registry(),
73 }
74 }
75
76 pub fn with_registry(mut self, registry: Arc<dyn Registry>) -> Self {
81 self.registry = Some(registry);
82 self
83 }
84
85 pub async fn add_lemma_files(
94 &mut self,
95 files: HashMap<String, String>,
96 ) -> Result<(), Vec<LemmaError>> {
97 let mut errors: Vec<LemmaError> = Vec::new();
98 let mut all_new_docs: Vec<LemmaDoc> = Vec::new();
99
100 for (source_id, code) in &files {
104 match parse(code, source_id, &self.limits) {
105 Ok(new_docs) => {
106 let source_text: Arc<str> = Arc::from(code.as_str());
107 for doc in new_docs {
108 let attribute = doc.attribute.clone().unwrap_or_else(|| doc.name.clone());
109
110 if let Some(existing) = self.documents.get(&doc.name) {
111 let earlier_attr =
112 existing.attribute.as_deref().unwrap_or(&existing.name);
113 errors.push(LemmaError::semantic(
114 format!(
115 "Duplicate document name '{}' (previously declared in '{}')",
116 doc.name, earlier_attr
117 ),
118 Some(crate::Source::new(
119 &attribute,
120 crate::parsing::ast::Span {
121 start: 0,
122 end: 0,
123 line: doc.start_line,
124 col: 0,
125 },
126 &doc.name,
127 source_text.clone(),
128 )),
129 None::<String>,
130 ));
131 } else {
132 self.sources.insert(attribute, code.clone());
133 self.documents.insert(doc.name.clone(), doc.clone());
134 }
135
136 all_new_docs.push(doc);
137 }
138 }
139 Err(e) => errors.push(e),
140 }
141 }
142
143 if let Some(registry) = &self.registry {
145 let docs_to_resolve: Vec<LemmaDoc> = self.documents.values().cloned().collect();
146 match crate::registry::resolve_registry_references(
147 docs_to_resolve,
148 &mut self.sources,
149 registry.as_ref(),
150 &self.limits,
151 )
152 .await
153 {
154 Ok(resolved_docs) => {
155 self.documents.clear();
156 for doc in resolved_docs {
157 self.documents.insert(doc.name.clone(), doc);
158 }
159 }
160 Err(e) => match e {
161 LemmaError::MultipleErrors(inner) => errors.extend(inner),
162 other => errors.push(other),
163 },
164 }
165 }
166
167 let docs_to_plan: Vec<&LemmaDoc> = all_new_docs.iter().collect();
169 let all_docs: Vec<LemmaDoc> = self.documents.values().cloned().collect();
170 let (plans, plan_errors) =
171 crate::planning::plan(&docs_to_plan, &all_docs, self.sources.clone());
172 self.execution_plans.extend(plans);
173 errors.extend(plan_errors);
174
175 if errors.is_empty() {
176 Ok(())
177 } else {
178 Err(errors)
179 }
180 }
181
182 pub fn remove_document(&mut self, doc_name: &str) {
183 self.execution_plans.remove(doc_name);
184 self.documents.remove(doc_name);
185 }
186
187 pub fn list_documents(&self) -> Vec<String> {
188 self.documents.keys().cloned().collect()
189 }
190
191 pub fn get_document(&self, doc_name: &str) -> Option<&LemmaDoc> {
192 self.documents.get(doc_name)
193 }
194
195 pub fn get_execution_plan(&self, doc_name: &str) -> Option<&crate::planning::ExecutionPlan> {
200 self.execution_plans.get(doc_name)
201 }
202
203 pub fn get_document_rules(&self, doc_name: &str) -> Vec<&crate::LemmaRule> {
204 if let Some(doc) = self.documents.get(doc_name) {
205 doc.rules.iter().collect()
206 } else {
207 Vec::new()
208 }
209 }
210
211 pub fn evaluate_json(
222 &self,
223 doc_name: &str,
224 rule_names: Vec<String>,
225 json: &[u8],
226 ) -> LemmaResult<Response> {
227 let base_plan = self.execution_plans.get(doc_name).ok_or_else(|| {
228 LemmaError::engine(
229 format!("Document '{}' not found", doc_name),
230 None,
231 None::<String>,
232 )
233 })?;
234
235 let values = crate::serialization::from_json(json, base_plan)?;
236 let plan = base_plan.clone().with_fact_values(values, &self.limits)?;
237
238 self.evaluate_plan(plan, rule_names)
239 }
240
241 pub fn evaluate(
253 &self,
254 doc_name: &str,
255 rule_names: Vec<String>,
256 fact_values: HashMap<String, String>,
257 ) -> LemmaResult<Response> {
258 let base_plan = self.execution_plans.get(doc_name).ok_or_else(|| {
259 LemmaError::engine(
260 format!("Document '{}' not found", doc_name),
261 None,
262 None::<String>,
263 )
264 })?;
265
266 let plan = base_plan
267 .clone()
268 .with_fact_values(fact_values, &self.limits)?;
269
270 self.evaluate_plan(plan, rule_names)
271 }
272
273 pub fn invert_json(
278 &self,
279 doc_name: &str,
280 rule_name: &str,
281 target: crate::inversion::Target,
282 json: &[u8],
283 ) -> LemmaResult<crate::InversionResponse> {
284 let base_plan = self.execution_plans.get(doc_name).ok_or_else(|| {
285 LemmaError::engine(
286 format!("Document '{}' not found", doc_name),
287 None,
288 None::<String>,
289 )
290 })?;
291
292 let values = crate::serialization::from_json(json, base_plan)?;
293 self.invert(doc_name, rule_name, target, values)
294 }
295
296 pub fn invert(
301 &self,
302 doc_name: &str,
303 rule_name: &str,
304 target: crate::inversion::Target,
305 values: HashMap<String, String>,
306 ) -> LemmaResult<crate::InversionResponse> {
307 let base_plan = self.execution_plans.get(doc_name).ok_or_else(|| {
308 LemmaError::engine(
309 format!("Document '{}' not found", doc_name),
310 None,
311 None::<String>,
312 )
313 })?;
314
315 let plan = base_plan.clone().with_fact_values(values, &self.limits)?;
316 let provided_facts: std::collections::HashSet<_> = plan
317 .facts
318 .iter()
319 .filter(|(_, d)| d.value().is_some())
320 .map(|(p, _)| p.clone())
321 .collect();
322
323 crate::inversion::invert(rule_name, target, &plan, &provided_facts)
324 }
325
326 fn evaluate_plan(
327 &self,
328 plan: crate::planning::ExecutionPlan,
329 rule_names: Vec<String>,
330 ) -> LemmaResult<Response> {
331 let mut response = self.evaluator.evaluate(&plan);
332
333 if !rule_names.is_empty() {
334 response.filter_rules(&rule_names);
335 }
336
337 Ok(response)
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use rust_decimal::Decimal;
345 use std::str::FromStr;
346
347 fn add_lemma_code_blocking(engine: &mut Engine, code: &str, source: &str) -> LemmaResult<()> {
348 let files: HashMap<String, String> =
349 std::iter::once((source.to_string(), code.to_string())).collect();
350 tokio::runtime::Runtime::new()
351 .expect("tokio runtime")
352 .block_on(engine.add_lemma_files(files))
353 .map_err(|errs| match errs.len() {
354 0 => unreachable!("add_lemma_files returned Err with empty error list"),
355 1 => errs.into_iter().next().unwrap(),
356 _ => LemmaError::MultipleErrors(errs),
357 })
358 }
359
360 #[test]
361 fn test_evaluate_document_all_rules() {
362 let mut engine = Engine::new();
363 add_lemma_code_blocking(
364 &mut engine,
365 r#"
366 doc test
367 fact x = 10
368 fact y = 5
369 rule sum = x + y
370 rule product = x * y
371 "#,
372 "test.lemma",
373 )
374 .unwrap();
375
376 let response = engine.evaluate("test", vec![], HashMap::new()).unwrap();
377 assert_eq!(response.results.len(), 2);
378
379 let sum_result = response
380 .results
381 .values()
382 .find(|r| r.rule.name == "sum")
383 .unwrap();
384 assert_eq!(
385 sum_result.result,
386 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
387 Decimal::from_str("15").unwrap()
388 )))
389 );
390
391 let product_result = response
392 .results
393 .values()
394 .find(|r| r.rule.name == "product")
395 .unwrap();
396 assert_eq!(
397 product_result.result,
398 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
399 Decimal::from_str("50").unwrap()
400 )))
401 );
402 }
403
404 #[test]
405 fn test_evaluate_empty_facts() {
406 let mut engine = Engine::new();
407 add_lemma_code_blocking(
408 &mut engine,
409 r#"
410 doc test
411 fact price = 100
412 rule total = price * 2
413 "#,
414 "test.lemma",
415 )
416 .unwrap();
417
418 let response = engine.evaluate("test", vec![], HashMap::new()).unwrap();
419 assert_eq!(response.results.len(), 1);
420 assert_eq!(
421 response.results.values().next().unwrap().result,
422 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
423 Decimal::from_str("200").unwrap()
424 )))
425 );
426 }
427
428 #[test]
429 fn test_evaluate_boolean_rule() {
430 let mut engine = Engine::new();
431 add_lemma_code_blocking(
432 &mut engine,
433 r#"
434 doc test
435 fact age = 25
436 rule is_adult = age >= 18
437 "#,
438 "test.lemma",
439 )
440 .unwrap();
441
442 let response = engine.evaluate("test", vec![], HashMap::new()).unwrap();
443 assert_eq!(
444 response.results.values().next().unwrap().result,
445 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::from_bool(true)))
446 );
447 }
448
449 #[test]
450 fn test_evaluate_with_unless_clause() {
451 let mut engine = Engine::new();
452 add_lemma_code_blocking(
453 &mut engine,
454 r#"
455 doc test
456 fact quantity = 15
457 rule discount = 0
458 unless quantity >= 10 then 10
459 "#,
460 "test.lemma",
461 )
462 .unwrap();
463
464 let response = engine.evaluate("test", vec![], HashMap::new()).unwrap();
465 assert_eq!(
466 response.results.values().next().unwrap().result,
467 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
468 Decimal::from_str("10").unwrap()
469 )))
470 );
471 }
472
473 #[test]
474 fn test_document_not_found() {
475 let engine = Engine::new();
476 let result = engine.evaluate("nonexistent", vec![], HashMap::new());
477 assert!(result.is_err());
478 assert!(result.unwrap_err().to_string().contains("not found"));
479 }
480
481 #[test]
482 fn test_multiple_documents() {
483 let mut engine = Engine::new();
484 add_lemma_code_blocking(
485 &mut engine,
486 r#"
487 doc doc1
488 fact x = 10
489 rule result = x * 2
490 "#,
491 "doc1.lemma",
492 )
493 .unwrap();
494
495 add_lemma_code_blocking(
496 &mut engine,
497 r#"
498 doc doc2
499 fact y = 5
500 rule result = y * 3
501 "#,
502 "doc2.lemma",
503 )
504 .unwrap();
505
506 let response1 = engine.evaluate("doc1", vec![], HashMap::new()).unwrap();
507 assert_eq!(
508 response1.results[0].result,
509 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
510 Decimal::from_str("20").unwrap()
511 )))
512 );
513
514 let response2 = engine.evaluate("doc2", vec![], HashMap::new()).unwrap();
515 assert_eq!(
516 response2.results[0].result,
517 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
518 Decimal::from_str("15").unwrap()
519 )))
520 );
521 }
522
523 #[test]
524 fn test_runtime_error_mapping() {
525 let mut engine = Engine::new();
526 add_lemma_code_blocking(
527 &mut engine,
528 r#"
529 doc test
530 fact numerator = 10
531 fact denominator = 0
532 rule division = numerator / denominator
533 "#,
534 "test.lemma",
535 )
536 .unwrap();
537
538 let result = engine.evaluate("test", vec![], HashMap::new());
539 assert!(result.is_ok(), "Evaluation should succeed");
541 let response = result.unwrap();
542 let division_result = response
543 .results
544 .values()
545 .find(|r| r.rule.name == "division");
546 assert!(
547 division_result.is_some(),
548 "Should have division rule result"
549 );
550 match &division_result.unwrap().result {
551 crate::OperationResult::Veto(message) => {
552 assert!(
553 message
554 .as_ref()
555 .map(|m| m.contains("Division by zero"))
556 .unwrap_or(false),
557 "Veto message should mention division by zero: {:?}",
558 message
559 );
560 }
561 other => panic!("Expected Veto for division by zero, got {:?}", other),
562 }
563 }
564
565 #[test]
566 fn test_rules_sorted_by_source_order() {
567 let mut engine = Engine::new();
568 add_lemma_code_blocking(
569 &mut engine,
570 r#"
571 doc test
572 fact a = 1
573 fact b = 2
574 rule z = a + b
575 rule y = a * b
576 rule x = a - b
577 "#,
578 "test.lemma",
579 )
580 .unwrap();
581
582 let response = engine.evaluate("test", vec![], HashMap::new()).unwrap();
583 assert_eq!(response.results.len(), 3);
584
585 let z_pos = response
587 .results
588 .values()
589 .find(|r| r.rule.name == "z")
590 .unwrap()
591 .rule
592 .source_location
593 .span
594 .start;
595 let y_pos = response
596 .results
597 .values()
598 .find(|r| r.rule.name == "y")
599 .unwrap()
600 .rule
601 .source_location
602 .span
603 .start;
604 let x_pos = response
605 .results
606 .values()
607 .find(|r| r.rule.name == "x")
608 .unwrap()
609 .rule
610 .source_location
611 .span
612 .start;
613
614 assert!(z_pos < y_pos);
615 assert!(y_pos < x_pos);
616 }
617
618 #[test]
619 fn test_rule_filtering_evaluates_dependencies() {
620 let mut engine = Engine::new();
621 add_lemma_code_blocking(
622 &mut engine,
623 r#"
624 doc test
625 fact base = 100
626 rule subtotal = base * 2
627 rule tax = subtotal? * 10%
628 rule total = subtotal? + tax?
629 "#,
630 "test.lemma",
631 )
632 .unwrap();
633
634 let response = engine
636 .evaluate("test", vec!["total".to_string()], HashMap::new())
637 .unwrap();
638
639 assert_eq!(response.results.len(), 1);
641 assert_eq!(response.results.keys().next().unwrap(), "total");
642
643 let total = response.results.values().next().unwrap();
645 assert_eq!(
646 total.result,
647 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
648 Decimal::from_str("220").unwrap()
649 )))
650 );
651 }
652
653 use crate::registry::{RegistryBundle, RegistryError};
658
659 struct EngineTestRegistry {
661 bundles: std::collections::HashMap<String, RegistryBundle>,
662 }
663
664 impl EngineTestRegistry {
665 fn new() -> Self {
666 Self {
667 bundles: std::collections::HashMap::new(),
668 }
669 }
670
671 fn add(&mut self, identifier: &str, source: &str) {
672 self.bundles.insert(
673 identifier.to_string(),
674 RegistryBundle {
675 lemma_source: source.to_string(),
676 attribute: format!("@{}", identifier),
677 },
678 );
679 }
680 }
681
682 #[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
683 #[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
684 impl Registry for EngineTestRegistry {
685 async fn resolve_doc(&self, identifier: &str) -> Result<RegistryBundle, RegistryError> {
686 self.bundles.get(identifier).cloned().ok_or(RegistryError {
687 message: format!("not found: {}", identifier),
688 kind: crate::registry::RegistryErrorKind::NotFound,
689 })
690 }
691
692 async fn resolve_type(&self, identifier: &str) -> Result<RegistryBundle, RegistryError> {
693 self.bundles.get(identifier).cloned().ok_or(RegistryError {
694 message: format!("not found: {}", identifier),
695 kind: crate::registry::RegistryErrorKind::NotFound,
696 })
697 }
698
699 fn url_for_id(&self, identifier: &str) -> Option<String> {
700 Some(format!("https://test/{}", identifier))
701 }
702 }
703
704 fn engine_without_registry() -> Engine {
706 Engine {
707 execution_plans: HashMap::new(),
708 documents: HashMap::new(),
709 sources: HashMap::new(),
710 evaluator: Evaluator,
711 limits: ResourceLimits::default(),
712 registry: None,
713 }
714 }
715
716 #[test]
717 fn add_lemma_files_with_registry_resolves_and_evaluates_external_doc() {
718 let mut registry = EngineTestRegistry::new();
719 registry.add(
720 "org/project/helper",
721 "doc org/project/helper\nfact quantity = 42",
722 );
723
724 let mut engine = engine_without_registry().with_registry(Arc::new(registry));
725
726 add_lemma_code_blocking(
727 &mut engine,
728 r#"doc main_doc
729fact external = doc @org/project/helper
730rule value = external.quantity"#,
731 "main.lemma",
732 )
733 .expect("add_lemma_files should succeed with registry resolving the external doc");
734
735 let response = engine
736 .evaluate("main_doc", vec![], HashMap::new())
737 .expect("evaluate should succeed");
738
739 let value_result = response
740 .results
741 .get("value")
742 .expect("rule 'value' should exist");
743 assert_eq!(
744 value_result.result,
745 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
746 Decimal::from_str("42").unwrap()
747 )))
748 );
749 }
750
751 #[test]
752 fn add_lemma_files_without_registry_and_no_external_refs_works() {
753 let mut engine = engine_without_registry();
754
755 add_lemma_code_blocking(
756 &mut engine,
757 r#"doc local_only
758fact price = 100
759rule doubled = price * 2"#,
760 "local.lemma",
761 )
762 .expect(
763 "add_lemma_files should succeed without registry when there are no @... references",
764 );
765
766 let response = engine
767 .evaluate("local_only", vec![], HashMap::new())
768 .expect("evaluate should succeed");
769
770 assert!(response.results.contains_key("doubled"));
771 }
772
773 #[test]
774 fn add_lemma_files_without_registry_and_external_ref_fails() {
775 let mut engine = engine_without_registry();
776
777 let result = add_lemma_code_blocking(
778 &mut engine,
779 r#"doc main_doc
780fact external = doc @org/project/missing
781rule value = external.quantity"#,
782 "main.lemma",
783 );
784
785 assert!(
786 result.is_err(),
787 "Should fail when @... reference exists but no registry is configured"
788 );
789 }
790
791 #[test]
792 fn add_lemma_files_with_registry_error_propagates_as_registry_error() {
793 let registry = EngineTestRegistry::new();
795
796 let mut engine = engine_without_registry().with_registry(Arc::new(registry));
797
798 let result = add_lemma_code_blocking(
799 &mut engine,
800 r#"doc main_doc
801fact external = doc @org/project/missing
802rule value = external.quantity"#,
803 "main.lemma",
804 );
805
806 assert!(
807 result.is_err(),
808 "Should fail when registry cannot resolve the @... reference"
809 );
810 let error = result.unwrap_err();
811 let registry_err = match &error {
812 LemmaError::Registry { .. } => &error,
813 LemmaError::MultipleErrors(inner) => inner
814 .iter()
815 .find(|e| matches!(e, LemmaError::Registry { .. }))
816 .expect("MultipleErrors should contain at least one Registry error"),
817 other => panic!(
818 "Expected LemmaError::Registry or MultipleErrors, got: {}",
819 other
820 ),
821 };
822 match registry_err {
823 LemmaError::Registry {
824 identifier, kind, ..
825 } => {
826 assert_eq!(identifier, "org/project/missing");
827 assert_eq!(*kind, crate::registry::RegistryErrorKind::NotFound);
828 }
829 _ => unreachable!(),
830 }
831 let error_message = error.to_string();
833 assert!(
834 error_message.contains("org/project/missing"),
835 "Error should mention the unresolved identifier: {}",
836 error_message
837 );
838 assert!(
839 error_message.contains("not found"),
840 "Error should mention the error kind: {}",
841 error_message
842 );
843 }
844
845 #[test]
846 fn with_registry_replaces_default_registry() {
847 let mut registry = EngineTestRegistry::new();
848 registry.add("custom/doc", "doc custom/doc\nfact x = 99");
849
850 let mut engine = Engine::new().with_registry(Arc::new(registry));
851
852 add_lemma_code_blocking(
853 &mut engine,
854 r#"doc main_doc
855fact ext = doc @custom/doc
856rule val = ext.x"#,
857 "main.lemma",
858 )
859 .expect("with_registry should replace the default registry");
860
861 let response = engine
862 .evaluate("main_doc", vec![], HashMap::new())
863 .expect("evaluate should succeed");
864
865 let val_result = response
866 .results
867 .get("val")
868 .expect("rule 'val' should exist");
869 assert_eq!(
870 val_result.result,
871 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
872 Decimal::from_str("99").unwrap()
873 )))
874 );
875 }
876
877 #[test]
878 fn add_lemma_files_returns_all_errors_not_just_first() {
879 let mut engine = engine_without_registry();
883
884 let result = add_lemma_code_blocking(
885 &mut engine,
886 r#"doc demo
887type money from nonexistent_type_source
888fact helper = doc nonexistent_doc
889fact price = 10
890rule total = helper.value + price"#,
891 "test.lemma",
892 );
893
894 assert!(result.is_err(), "Should fail with multiple errors");
895 let error = result.unwrap_err();
896 let error_message = error.to_string();
897
898 assert!(
900 error_message.contains("money"),
901 "Should mention type error about 'money'. Got:\n{}",
902 error_message
903 );
904
905 assert!(
907 error_message.contains("nonexistent_doc"),
908 "Should mention doc reference error about 'nonexistent_doc'. Got:\n{}",
909 error_message
910 );
911
912 assert!(
914 matches!(error, LemmaError::MultipleErrors(_)),
915 "Expected MultipleErrors, got: {}",
916 error_message
917 );
918 }
919
920 #[test]
926 fn planning_rejects_invalid_number_default() {
927 let mut engine = Engine::new();
928 let result = add_lemma_code_blocking(
929 &mut engine,
930 "doc t\nfact x = [number -> default \"10 $$\"]\nrule r = x",
931 "t.lemma",
932 );
933 assert!(
934 result.is_err(),
935 "must reject non-numeric default on number type"
936 );
937 }
938
939 #[test]
940 fn planning_rejects_text_literal_as_number_default() {
941 let mut engine = Engine::new();
946 let result = add_lemma_code_blocking(
947 &mut engine,
948 "doc t\nfact x = [number -> default \"10\"]\nrule r = x",
949 "t.lemma",
950 );
951 assert!(
952 result.is_err(),
953 "must reject text literal \"10\" as default for number type"
954 );
955 }
956
957 #[test]
958 fn planning_rejects_invalid_boolean_default() {
959 let mut engine = Engine::new();
960 let result = add_lemma_code_blocking(
961 &mut engine,
962 "doc t\nfact x = [boolean -> default \"maybe\"]\nrule r = x",
963 "t.lemma",
964 );
965 assert!(
966 result.is_err(),
967 "must reject non-boolean default on boolean type"
968 );
969 }
970
971 #[test]
972 fn planning_rejects_invalid_named_type_default() {
973 let mut engine = Engine::new();
975 let result = add_lemma_code_blocking(
976 &mut engine,
977 "doc t\ntype custom = number -> minimum 0\nfact x = [custom -> default \"abc\"]\nrule r = x",
978 "t.lemma",
979 );
980 assert!(
981 result.is_err(),
982 "must reject non-numeric default on named number type"
983 );
984 }
985
986 #[test]
987 fn planning_accepts_valid_number_default() {
988 let mut engine = Engine::new();
989 let result = add_lemma_code_blocking(
990 &mut engine,
991 "doc t\nfact x = [number -> default 10]\nrule r = x",
992 "t.lemma",
993 );
994 assert!(result.is_ok(), "must accept valid number default");
995 }
996
997 #[test]
998 fn planning_accepts_valid_boolean_default() {
999 let mut engine = Engine::new();
1000 let result = add_lemma_code_blocking(
1001 &mut engine,
1002 "doc t\nfact x = [boolean -> default true]\nrule r = x",
1003 "t.lemma",
1004 );
1005 assert!(result.is_ok(), "must accept valid boolean default");
1006 }
1007
1008 #[test]
1009 fn planning_accepts_valid_text_default() {
1010 let mut engine = Engine::new();
1011 let result = add_lemma_code_blocking(
1012 &mut engine,
1013 "doc t\nfact x = [text -> default \"hello\"]\nrule r = x",
1014 "t.lemma",
1015 );
1016 assert!(result.is_ok(), "must accept valid text default");
1017 }
1018}