1use std::sync::Arc;
12
13use sim_codec::{
14 Input, decode_eval_expr_with_codec, decode_with_codec, encode_value_with_codec,
15 lower_operator_nodes,
16};
17use sim_cookbook::{CheckResult, RecipeCard, RecipeRun};
18use sim_kernel::{
19 CapabilityName, CapabilitySet, Cx, EncodeOptions, Error, Expr, ReadPolicy, Result, Shape,
20 Symbol, TrustLevel, macro_expand_eval_capability, read_construct_capability,
21 read_eval_capability,
22};
23use sim_lib_core::{ReadEvalBroker, ReadEvalRequest, ReadEvalSource, RequestOrigin};
24use sim_shape::AnyShape;
25
26use crate::catalog::{CookbookCapabilityProfile, EmptyCatalog, LibCatalog, load_requires};
27
28pub fn require_eval_capability(cx: &Cx) -> Result<()> {
30 if cx.capabilities().contains(&read_eval_capability()) {
31 Ok(())
32 } else {
33 Err(Error::CapabilityDenied {
34 capability: read_eval_capability(),
35 })
36 }
37}
38
39pub fn missing_requires(cx: &Cx, card: &RecipeCard) -> Vec<String> {
45 let loaded: Vec<(String, String)> = cx
46 .registry()
47 .libs()
48 .iter()
49 .map(|lib| {
50 (
51 lib.manifest.id.as_qualified_str(),
52 lib.manifest.id.name.to_string(),
53 )
54 })
55 .collect();
56 card.requires
57 .iter()
58 .filter(|req| {
59 !loaded
60 .iter()
61 .any(|(qualified, name)| qualified == *req || name == *req)
62 })
63 .cloned()
64 .collect()
65}
66
67pub fn run_recipe(cx: &mut Cx, card: &RecipeCard) -> Result<RecipeRun> {
74 run_recipe_with_catalog(cx, &EmptyCatalog, card)
75}
76
77pub fn run_recipe_with_catalog(
86 cx: &mut Cx,
87 catalog: &dyn LibCatalog,
88 card: &RecipeCard,
89) -> Result<RecipeRun> {
90 run_recipe_with_catalog_shape(cx, catalog, card, recipe_result_shape())
91}
92
93fn run_recipe_with_catalog_shape(
94 cx: &mut Cx,
95 catalog: &dyn LibCatalog,
96 card: &RecipeCard,
97 expected_shape: Arc<dyn Shape>,
98) -> Result<RecipeRun> {
99 require_eval_capability(cx)?;
100
101 let unresolved = load_requires(cx, catalog, card);
102 if !unresolved.is_empty() {
103 return Err(Error::Eval(format!(
104 "recipe {} descriptor: requires not in catalog: {}",
105 card.id,
106 unresolved.join(", ")
107 )));
108 }
109
110 let codec = Symbol::qualified("codec", card.codec.as_str());
111 let source = String::from_utf8(card.setup.clone())
112 .map_err(|e| Error::Eval(format!("recipe {} setup is not UTF-8: {e}", card.id)))?;
113 let expr = lower_operator_nodes(decode_eval_expr_with_codec(
117 cx,
118 &codec,
119 Input::Text(source),
120 trusted_recipe_read_policy(),
121 )?);
122
123 let request = recipe_read_eval_request(
124 card,
125 codec.clone(),
126 ReadEvalSource::Expr(expr),
127 expected_shape,
128 );
129 let broker = ReadEvalBroker::new();
130 let (results, eval_ok) = match broker.admit(cx, request) {
131 Ok(value) => {
132 let encoded = encode_value_with_codec(cx, &codec, &value, EncodeOptions::default())
139 .or_else(|_| {
140 let lisp = Symbol::qualified("codec", "lisp");
141 encode_value_with_codec(cx, &lisp, &value, EncodeOptions::default())
142 })?;
143 (vec![encoded.into_text()?], true)
144 }
145 Err(err) if is_hard_broker_error(&err) => return Err(err),
146 Err(_) => (Vec::new(), false),
147 };
148
149 let mut checks = Vec::new();
150 let mut all_pass = true;
151 for expectation in &card.expect {
152 let actual = results.get(expectation.form).cloned();
153 let pass = actual.as_deref() == Some(expectation.result.as_str());
154 if !pass {
155 all_pass = false;
156 }
157 checks.push(CheckResult {
158 form: expectation.form,
159 expected: expectation.result.clone(),
160 actual: actual.unwrap_or_else(|| "<no such form>".to_string()),
161 pass,
162 });
163 }
164
165 Ok(RecipeRun {
166 recipe: card.id.clone(),
167 forms: results.len(),
168 results,
169 ok: eval_ok && all_pass,
170 checks,
171 })
172}
173
174#[cfg(test)]
175pub(crate) fn run_recipe_with_catalog_for_shape_test(
176 cx: &mut Cx,
177 catalog: &dyn LibCatalog,
178 card: &RecipeCard,
179 expected_shape: Arc<dyn Shape>,
180) -> Result<RecipeRun> {
181 run_recipe_with_catalog_shape(cx, catalog, card, expected_shape)
182}
183
184fn recipe_read_eval_request(
185 card: &RecipeCard,
186 codec: Symbol,
187 source: ReadEvalSource,
188 expected_shape: Arc<dyn Shape>,
189) -> ReadEvalRequest {
190 ReadEvalRequest {
191 origin: RequestOrigin::with_detail(
192 Symbol::qualified("cookbook", "recipe"),
193 Expr::String(card.id.clone()),
194 ),
195 codec,
196 source,
197 read_policy: trusted_recipe_read_policy(),
198 requires: recipe_required_capabilities(card),
199 allow: recipe_allowed_capabilities(card),
200 expected_shape,
201 }
202}
203
204fn recipe_result_shape() -> Arc<dyn Shape> {
205 Arc::new(AnyShape)
206}
207
208fn trusted_recipe_read_policy() -> ReadPolicy {
209 ReadPolicy {
210 trust: TrustLevel::TrustedSource,
211 capabilities: CapabilitySet::new()
212 .grant(read_construct_capability())
213 .grant(read_eval_capability())
214 .grant(macro_expand_eval_capability()),
215 }
216}
217
218fn recipe_required_capabilities(card: &RecipeCard) -> Vec<CapabilityName> {
219 let mut capabilities = vec![read_eval_capability(), macro_expand_eval_capability()];
220 capabilities.extend(tagged_capabilities(card, "requires-capability:"));
221 sort_dedup_capabilities(&mut capabilities);
222 capabilities
223}
224
225fn recipe_allowed_capabilities(card: &RecipeCard) -> CapabilitySet {
226 let mut capabilities = tagged_capabilities(card, "allow-capability:");
227 if capabilities.is_empty() {
228 capabilities = CookbookCapabilityProfile::granted();
229 }
230 capabilities.extend(recipe_required_capabilities(card));
231 capabilities.push(read_construct_capability());
232 sort_dedup_capabilities(&mut capabilities);
233 capabilities
234 .into_iter()
235 .fold(CapabilitySet::new(), CapabilitySet::grant)
236}
237
238fn tagged_capabilities(card: &RecipeCard, prefix: &str) -> Vec<CapabilityName> {
239 card.tags
240 .iter()
241 .filter_map(|tag| {
242 let name = tag.strip_prefix(prefix)?;
243 (!name.is_empty()).then(|| CapabilityName::new(name.to_owned()))
244 })
245 .collect()
246}
247
248fn sort_dedup_capabilities(capabilities: &mut Vec<CapabilityName>) {
249 capabilities.sort();
250 capabilities.dedup();
251}
252
253fn is_hard_broker_error(err: &Error) -> bool {
254 matches!(
255 err,
256 Error::CapabilityDenied { .. }
257 | Error::TrustDenied { .. }
258 | Error::WrongShape { .. }
259 | Error::CodecError { .. }
260 )
261}
262
263pub fn run_recipe_twice(
273 cx: &mut Cx,
274 catalog: &dyn LibCatalog,
275 card: &RecipeCard,
276) -> Result<RecipeRun> {
277 let first = run_recipe_with_catalog(cx, catalog, card)?;
278 let second = run_recipe_with_catalog(cx, catalog, card)?;
279 if first.results != second.results {
280 return Err(Error::Eval(format!(
281 "recipe {} is not deterministic: {:?} != {:?}",
282 card.id, first.results, second.results
283 )));
284 }
285 Ok(first)
286}
287
288pub fn decode_setup(cx: &mut Cx, card: &RecipeCard) -> Result<sim_kernel::Expr> {
290 let codec = Symbol::qualified("codec", card.codec.as_str());
291 let source = String::from_utf8(card.setup.clone())
292 .map_err(|e| Error::Eval(format!("recipe {} setup is not UTF-8: {e}", card.id)))?;
293 decode_with_codec(cx, &codec, Input::Text(source), ReadPolicy::default())
294}