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    CapabilitySet, Cx, EncodeOptions, Error, Expr, ReadPolicy, Result, Symbol, TrustLevel,
16    read_construct_capability, read_eval_capability,
17};
18
19/// Error unless the runtime holds the read-eval capability.
20pub fn require_eval_capability(cx: &Cx) -> Result<()> {
21    if cx.capabilities().contains(&read_eval_capability()) {
22        Ok(())
23    } else {
24        Err(Error::CapabilityDenied {
25            capability: read_eval_capability(),
26        })
27    }
28}
29
30/// Lib ids in `card.requires` that are not currently loaded.
31///
32/// A requirement matches a loaded lib by its fully qualified id
33/// (`namespace/name`) or by its unqualified `name` alone, so `numbers-f64`
34/// matches a lib whose id is `lisp/numbers-f64`.
35pub fn missing_requires(cx: &Cx, card: &RecipeCard) -> Vec<String> {
36    let loaded: Vec<(String, String)> = cx
37        .registry()
38        .libs()
39        .iter()
40        .map(|lib| {
41            (
42                lib.manifest.id.as_qualified_str(),
43                lib.manifest.id.name.to_string(),
44            )
45        })
46        .collect();
47    card.requires
48        .iter()
49        .filter(|req| {
50            !loaded
51                .iter()
52                .any(|(qualified, name)| qualified == *req || name == *req)
53        })
54        .cloned()
55        .collect()
56}
57
58/// Run a recipe end to end. Hard errors (missing requires, unknown codec,
59/// undecodable setup) return `Err`; an evaluation error is captured as
60/// `ok == false` with empty results so the caller still sees a `RecipeRun`.
61pub fn run_recipe(cx: &mut Cx, card: &RecipeCard) -> Result<RecipeRun> {
62    require_eval_capability(cx)?;
63
64    let missing = missing_requires(cx, card);
65    if !missing.is_empty() {
66        return Err(Error::Eval(format!(
67            "recipe {} requires libs not loaded: {}",
68            card.id,
69            missing.join(", ")
70        )));
71    }
72
73    let codec = Symbol::qualified("codec", card.codec.as_str());
74    let source = String::from_utf8(card.setup.clone())
75        .map_err(|e| Error::Eval(format!("recipe {} setup is not UTF-8: {e}", card.id)))?;
76    // Decode to an evaluable TERM (per the codec's own lowering policy) so `(math/add
77    // 1.5 2.0)` becomes a call the runtime APPLIES -- not an inert data list. This is why
78    // recipes were historically limited to `(quote ...)` canaries: the old data-position
79    // decode turned a bare `(f x)` into a list, never a call, so nothing ran. `decode_term`
80    // lowers a lisp surface list to a call AND accepts a data codec's explicit call form
81    // (e.g. JSON's tagged `call`), so both codecs evaluate.
82    // Recipes are trusted embedded content; grant the read the capabilities their setup
83    // needs -- read-construct so `#(Class ...)` builds a real domain value (complex,
84    // tensor, rational, CAS), and read-eval for eval-position reader forms.
85    // `ReadPolicy::default()` grants neither, which is why domain recipes could only quote.
86    let read_policy = ReadPolicy {
87        trust: TrustLevel::TrustedSource,
88        capabilities: CapabilitySet::new()
89            .grant(read_construct_capability())
90            .grant(read_eval_capability()),
91    };
92    let expr = Expr::from(decode_term_with_codec(
93        cx,
94        &codec,
95        Input::Text(source),
96        read_policy,
97    )?);
98
99    let (results, eval_ok) = match cx.eval_expr(expr) {
100        Ok(value) => {
101            let text = encode_value_with_codec(cx, &codec, &value, EncodeOptions::default())?
102                .into_text()?;
103            (vec![text], true)
104        }
105        Err(_) => (Vec::new(), false),
106    };
107
108    let mut checks = Vec::new();
109    let mut all_pass = true;
110    for expectation in &card.expect {
111        let actual = results.get(expectation.form).cloned();
112        let pass = actual.as_deref() == Some(expectation.result.as_str());
113        if !pass {
114            all_pass = false;
115        }
116        checks.push(CheckResult {
117            form: expectation.form,
118            expected: expectation.result.clone(),
119            actual: actual.unwrap_or_else(|| "<no such form>".to_string()),
120            pass,
121        });
122    }
123
124    Ok(RecipeRun {
125        recipe: card.id.clone(),
126        forms: results.len(),
127        results,
128        ok: eval_ok && all_pass,
129        checks,
130    })
131}
132
133/// Decode a recipe's setup to an `Expr` without evaluating it (`cookbook:setup`).
134pub fn decode_setup(cx: &mut Cx, card: &RecipeCard) -> Result<sim_kernel::Expr> {
135    let codec = Symbol::qualified("codec", card.codec.as_str());
136    let source = String::from_utf8(card.setup.clone())
137        .map_err(|e| Error::Eval(format!("recipe {} setup is not UTF-8: {e}", card.id)))?;
138    decode_with_codec(cx, &codec, Input::Text(source), ReadPolicy::default())
139}