Skip to main content

lemma/
engine.rs

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
8/// Engine for evaluating Lemma rules
9///
10/// Pure Rust implementation that evaluates Lemma docs directly from the AST.
11/// Uses pre-built execution plans that are self-contained and ready for evaluation.
12///
13/// An optional Registry can be configured to resolve external `@...` references.
14/// When a Registry is set, `add_lemma_files` will automatically resolve `@...`
15/// references by fetching source text from the Registry, parsing it, and including
16/// the resulting Lemma docs in the document set before planning.
17pub 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    /// Return the default registry based on enabled features.
45    ///
46    /// When the `registry` feature is enabled, the default registry is `LemmaBase`,
47    /// which resolves `@...` references by fetching Lemma source from LemmaBase.com.
48    ///
49    /// When the `registry` feature is disabled, no registry is configured and
50    /// `@...` references will fail during resolution.
51    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    /// Create an engine with custom resource limits.
63    ///
64    /// Uses the default registry (LemmaBase when the `registry` feature is enabled).
65    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    /// Configure a Registry for resolving external `@...` references.
77    ///
78    /// When set, `add_lemma_files` will resolve `@...` references automatically
79    /// by fetching source text from the Registry before planning.
80    pub fn with_registry(mut self, registry: Arc<dyn Registry>) -> Self {
81        self.registry = Some(registry);
82        self
83    }
84
85    /// Add Lemma source files and (when a registry is configured) resolve any `@...` references.
86    ///
87    /// - Resolves registry references **once** for all documents
88    /// - Validates and resolves types **once** across all documents
89    /// - Collects **all** errors across all files (parse, registry, planning) instead of aborting on the first
90    ///
91    /// `files` maps source identifiers (e.g. file paths) to source code.
92    /// For a single file, pass a one-entry `HashMap`.
93    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        // 1. Parse all files, collect parse errors and detect duplicate document names.
101        //    Duplicates are checked against both existing documents (from prior calls)
102        //    and documents parsed earlier in this same call.
103        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        // 2. Resolve registry references once for all documents
144        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        // 3. Plan all new documents at once (validates and resolves types once)
168        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    /// Get the execution plan for a document.
196    ///
197    /// The execution plan contains the resolved fact schema, default values,
198    /// and topologically sorted rules ready for evaluation.
199    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    /// Evaluate rules in a document with JSON values for facts.
212    ///
213    /// This is a convenience method that accepts JSON directly and converts it
214    /// to typed values using the document's fact type declarations.
215    ///
216    /// If `rule_names` is empty, evaluates all rules.
217    /// Otherwise, only returns results for the specified rules (dependencies still computed).
218    ///
219    /// Values are provided as JSON bytes (e.g., `b"{\"quantity\": 5, \"is_member\": true}"`).
220    /// They are automatically parsed to the expected type based on the document schema.
221    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    /// Evaluate rules in a document with string values for facts.
242    ///
243    /// This is the user-friendly API that accepts raw string values and parses them
244    /// to the appropriate types based on the document's fact type declarations.
245    /// Use this for CLI, HTTP APIs, and other user-facing interfaces.
246    ///
247    /// If `rule_names` is empty, evaluates all rules.
248    /// Otherwise, only returns results for the specified rules (dependencies still computed).
249    ///
250    /// Fact values are provided as name -> value string pairs (e.g., "type" -> "latte").
251    /// They are automatically parsed to the expected type based on the document schema.
252    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    /// Invert a rule to find input domains that produce a desired outcome with JSON values.
274    ///
275    /// Values are provided as JSON bytes (e.g., `b"{\"quantity\": 5, \"is_member\": true}"`).
276    /// They are automatically parsed to the expected type based on the document schema.
277    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    /// Invert a rule to find input domains that produce a desired outcome.
297    ///
298    /// Values are provided as name -> value string pairs (e.g., "quantity" -> "5").
299    /// They are automatically parsed to the expected type based on the document schema.
300    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        // Division by zero returns a Veto (not an error)
540        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        // Verify source positions increase (z < y < x)
586        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        // Request only 'total', but it depends on 'subtotal' and 'tax'
635        let response = engine
636            .evaluate("test", vec!["total".to_string()], HashMap::new())
637            .unwrap();
638
639        // Only 'total' should be in results
640        assert_eq!(response.results.len(), 1);
641        assert_eq!(response.results.keys().next().unwrap(), "total");
642
643        // But the value should be correct (dependencies were computed)
644        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    // -------------------------------------------------------------------
654    // Registry integration tests
655    // -------------------------------------------------------------------
656
657    use crate::registry::{RegistryBundle, RegistryError};
658
659    /// Minimal test registry for engine-level tests.
660    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    /// Build an engine with no registry (regardless of feature flags).
705    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        // Empty registry — every lookup returns "not found"
794        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        // The Display output should also mention the identifier and kind.
832        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        // When a document has multiple independent errors (type import from
880        // non-existing doc AND doc reference to non-existing doc), the Engine
881        // should surface all of them, not just the first one.
882        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        // The type resolution error should be present
899        assert!(
900            error_message.contains("money"),
901            "Should mention type error about 'money'. Got:\n{}",
902            error_message
903        );
904
905        // The doc reference error should ALSO be present (not swallowed)
906        assert!(
907            error_message.contains("nonexistent_doc"),
908            "Should mention doc reference error about 'nonexistent_doc'. Got:\n{}",
909            error_message
910        );
911
912        // The error should be a MultipleErrors variant since there are 2+ errors
913        assert!(
914            matches!(error, LemmaError::MultipleErrors(_)),
915            "Expected MultipleErrors, got: {}",
916            error_message
917        );
918    }
919
920    // ── Default value type validation ────────────────────────────────
921    // Planning must reject default values that don't match the type.
922    // These tests cover both primitives and named types (which the parser
923    // can't validate because it doesn't resolve type names).
924
925    #[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        // The parser produces CommandArg::Text("10") for `default "10"`.
942        // Planning now checks the CommandArg variant: a Text literal is
943        // rejected where a Number literal is required, even though the
944        // string content "10" could be parsed as a valid Decimal.
945        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        // Named type: the parser can't validate this, only planning can.
974        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}