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