Skip to main content

sim_lib_cookbook/
run.rs

1//! The `cookbook:run` algorithm: decode a recipe's setup through its declared
2//! codec, evaluate it, encode the result, and check declared expectations.
3//!
4//! Per the design (Section 7) this is capability-gated: running a recipe is
5//! read-eval, so the runtime must hold the read-eval capability. Listing and
6//! showing recipes is not gated.
7//!
8//! First cut: the setup decodes to a single top-level form (form 0). The data
9//! model already carries a `Vec` of results and expectations, so multi-form
10//! setups are a forward-compatible extension.
11
12use sim_codec::{Input, decode_term_with_codec, decode_with_codec, encode_value_with_codec};
13use sim_cookbook::{CheckResult, RecipeCard, RecipeRun};
14use sim_kernel::{
15    Cx, EncodeOptions, Error, Expr, ReadPolicy, Result, Symbol, read_eval_capability,
16};
17
18/// Error unless the runtime holds the read-eval capability.
19pub fn require_eval_capability(cx: &Cx) -> Result<()> {
20    if cx.capabilities().contains(&read_eval_capability()) {
21        Ok(())
22    } else {
23        Err(Error::CapabilityDenied {
24            capability: read_eval_capability(),
25        })
26    }
27}
28
29/// Lib ids in `card.requires` that are not currently loaded.
30///
31/// A requirement matches a loaded lib by its fully qualified id
32/// (`namespace/name`) or by its unqualified `name` alone, so `numbers-f64`
33/// matches a lib whose id is `lisp/numbers-f64`.
34pub fn missing_requires(cx: &Cx, card: &RecipeCard) -> Vec<String> {
35    let loaded: Vec<(String, String)> = cx
36        .registry()
37        .libs()
38        .iter()
39        .map(|lib| {
40            (
41                lib.manifest.id.as_qualified_str(),
42                lib.manifest.id.name.to_string(),
43            )
44        })
45        .collect();
46    card.requires
47        .iter()
48        .filter(|req| {
49            !loaded
50                .iter()
51                .any(|(qualified, name)| qualified == *req || name == *req)
52        })
53        .cloned()
54        .collect()
55}
56
57/// Run a recipe end to end. Hard errors (missing requires, unknown codec,
58/// undecodable setup) return `Err`; an evaluation error is captured as
59/// `ok == false` with empty results so the caller still sees a `RecipeRun`.
60pub fn run_recipe(cx: &mut Cx, card: &RecipeCard) -> Result<RecipeRun> {
61    require_eval_capability(cx)?;
62
63    let missing = missing_requires(cx, card);
64    if !missing.is_empty() {
65        return Err(Error::Eval(format!(
66            "recipe {} requires libs not loaded: {}",
67            card.id,
68            missing.join(", ")
69        )));
70    }
71
72    let codec = Symbol::qualified("codec", card.codec.as_str());
73    let source = String::from_utf8(card.setup.clone())
74        .map_err(|e| Error::Eval(format!("recipe {} setup is not UTF-8: {e}", card.id)))?;
75    // Decode to an evaluable TERM (per the codec's own lowering policy) so `(math/add
76    // 1.5 2.0)` becomes a call the runtime APPLIES -- not an inert data list. This is why
77    // recipes were historically limited to `(quote ...)` canaries: the old data-position
78    // decode turned a bare `(f x)` into a list, never a call, so nothing ran. `decode_term`
79    // lowers a lisp surface list to a call AND accepts a data codec's explicit call form
80    // (e.g. JSON's tagged `call`), so both codecs evaluate.
81    let expr = Expr::from(decode_term_with_codec(
82        cx,
83        &codec,
84        Input::Text(source),
85        ReadPolicy::default(),
86    )?);
87
88    let (results, eval_ok) = match cx.eval_expr(expr) {
89        Ok(value) => {
90            let text = encode_value_with_codec(cx, &codec, &value, EncodeOptions::default())?
91                .into_text()?;
92            (vec![text], true)
93        }
94        Err(_) => (Vec::new(), false),
95    };
96
97    let mut checks = Vec::new();
98    let mut all_pass = true;
99    for expectation in &card.expect {
100        let actual = results.get(expectation.form).cloned();
101        let pass = actual.as_deref() == Some(expectation.result.as_str());
102        if !pass {
103            all_pass = false;
104        }
105        checks.push(CheckResult {
106            form: expectation.form,
107            expected: expectation.result.clone(),
108            actual: actual.unwrap_or_else(|| "<no such form>".to_string()),
109            pass,
110        });
111    }
112
113    Ok(RecipeRun {
114        recipe: card.id.clone(),
115        forms: results.len(),
116        results,
117        ok: eval_ok && all_pass,
118        checks,
119    })
120}
121
122/// Decode a recipe's setup to an `Expr` without evaluating it (`cookbook:setup`).
123pub fn decode_setup(cx: &mut Cx, card: &RecipeCard) -> Result<sim_kernel::Expr> {
124    let codec = Symbol::qualified("codec", card.codec.as_str());
125    let source = String::from_utf8(card.setup.clone())
126        .map_err(|e| Error::Eval(format!("recipe {} setup is not UTF-8: {e}", card.id)))?;
127    decode_with_codec(cx, &codec, Input::Text(source), ReadPolicy::default())
128}