Skip to main content

sim_lib_cookbook/
loadable.rs

1//! Dynamic projection from a host loadable-lib directory to recipe cards.
2
3use std::sync::Arc;
4
5use sim_cookbook::{
6    EmbeddedDir, RecipeCard, RecipeRun, RecipeSource, RecipeStore, recipes_from_embedded,
7};
8use sim_kernel::{Cx, Error, Lib, LibId, Result, Symbol};
9
10use crate::catalog::LibCatalog;
11
12/// Host-owned factory for constructing a loadable library.
13pub type LibFactory = Arc<dyn Fn() -> Box<dyn Lib + Send + Sync> + Send + Sync>;
14
15/// Lifecycle command encoded by a synthetic cookbook card.
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum LifecycleAction {
18    /// Load a known library from the host-owned directory.
19    Load,
20    /// Unload a currently loaded library with the kernel's non-cascade unload.
21    Unload,
22}
23
24impl LifecycleAction {
25    /// Stable action tag value used by lifecycle cards.
26    pub fn as_str(self) -> &'static str {
27        match self {
28            Self::Load => "load",
29            Self::Unload => "unload",
30        }
31    }
32}
33
34/// One known library in the cookbook's effective loadable-lib directory.
35pub struct LoadableLibEntry {
36    /// Cookbook-facing library id, such as `numbers/cas`.
37    pub id: String,
38    /// Host resolver source key, such as `symbol:numbers/cas`.
39    pub source: String,
40    /// Human title for this library's cookbook book.
41    pub title: String,
42    /// Book display order.
43    pub order: i64,
44    /// Embedded recipes for this lib, when the host can expose them.
45    pub recipes: Option<EmbeddedDir>,
46    /// Catalog instance that resolves ordinary recipe `requires`.
47    pub catalog_lib: Box<dyn Lib + Send + Sync>,
48    /// Factory used by lifecycle execution to create a fresh lib instance.
49    pub factory: LibFactory,
50}
51
52/// Effective directory of host-loadable libraries known to the cookbook.
53pub struct LoadableLibList {
54    entries: Vec<LoadableLibEntry>,
55}
56
57impl LoadableLibList {
58    /// Creates a directory from ordered entries.
59    pub fn new(entries: Vec<LoadableLibEntry>) -> Self {
60        Self { entries }
61    }
62
63    /// Borrows the entries in display order.
64    pub fn entries(&self) -> &[LoadableLibEntry] {
65        &self.entries
66    }
67
68    /// Finds an entry by exact cookbook id.
69    pub fn entry(&self, id: &str) -> Option<&LoadableLibEntry> {
70        self.entries.iter().find(|entry| entry.id == id)
71    }
72
73    /// Whether a matching library is already loaded in `cx`.
74    pub fn is_loaded(cx: &Cx, id: &str) -> bool {
75        Self::loaded_id(cx, id).is_some()
76    }
77
78    /// Load a known library from its host factory.
79    ///
80    /// Calling this for an already-loaded id is a successful no-op, so a stale
81    /// load card cannot duplicate registry entries.
82    pub fn load(&self, cx: &mut Cx, id: &str) -> Result<String> {
83        let entry = self
84            .entry(id)
85            .ok_or_else(|| Error::Eval(format!("unknown loadable lib `{id}`")))?;
86        if Self::loaded_entry_id(cx, entry).is_some() {
87            return Ok(format!("already loaded {id}"));
88        }
89        let lib = (entry.factory)();
90        cx.load_lib(lib.as_ref())?;
91        Ok(format!("loaded {id}"))
92    }
93
94    /// Unload a loaded library with the kernel's bare, non-cascade unload.
95    ///
96    /// If another loaded lib depends on this one, the kernel refusal is returned
97    /// unchanged to the lifecycle runner, which reports it as `ok:false`.
98    pub fn unload(&self, cx: &mut Cx, id: &str) -> Result<String> {
99        let loaded_id = self
100            .entry(id)
101            .and_then(|entry| Self::loaded_entry_id(cx, entry))
102            .or_else(|| Self::loaded_id(cx, id))
103            .ok_or_else(|| Error::Eval(format!("lib `{id}` is not loaded")))?;
104        cx.unload_lib(loaded_id)?;
105        Ok(format!("unloaded {id}"))
106    }
107
108    fn loaded_id(cx: &Cx, id: &str) -> Option<LibId> {
109        cx.registry()
110            .libs()
111            .iter()
112            .find(|loaded| lib_id_matches(&loaded.manifest.id, id))
113            .map(|loaded| loaded.id)
114    }
115
116    fn loaded_entry_id(cx: &Cx, entry: &LoadableLibEntry) -> Option<LibId> {
117        let manifest_id = entry.catalog_lib.manifest().id;
118        cx.registry()
119            .libs()
120            .iter()
121            .find(|loaded| {
122                loaded.manifest.id == manifest_id
123                    || lib_id_matches(&loaded.manifest.id, &entry.id)
124                    || lib_id_matches(&loaded.manifest.id, &manifest_id.as_qualified_str())
125            })
126            .map(|loaded| loaded.id)
127    }
128}
129
130impl LibCatalog for LoadableLibList {
131    fn resolve(&self, name: &str) -> Option<&dyn Lib> {
132        self.entries
133            .iter()
134            .find(|entry| {
135                entry.id == name
136                    || entry.id.rsplit('/').next() == Some(name)
137                    || lib_id_matches(&entry.catalog_lib.manifest().id, name)
138            })
139            .map(|entry| entry.catalog_lib.as_ref() as &dyn Lib)
140    }
141}
142
143fn lib_id_matches(symbol: &Symbol, id: &str) -> bool {
144    let tail = id.rsplit('/').next().unwrap_or(id);
145    let qualified = symbol.as_qualified_str();
146    qualified == id
147        || qualified.replace('/', "-") == id
148        || symbol.name.as_ref() == id
149        || symbol.name.as_ref() == tail
150}
151
152/// Reads the lifecycle action encoded by a synthetic cookbook card.
153pub fn lifecycle_action(card: &RecipeCard) -> Option<(LifecycleAction, String)> {
154    let action = card
155        .tags
156        .iter()
157        .find_map(|tag| tag.strip_prefix("cookbook-action:"))?;
158    let lib = card
159        .tags
160        .iter()
161        .find_map(|tag| tag.strip_prefix("cookbook-lib:"))?;
162    let action = match action {
163        "load" => LifecycleAction::Load,
164        "unload" => LifecycleAction::Unload,
165        _ => return None,
166    };
167    Some((action, lib.to_owned()))
168}
169
170/// Run a cookbook card against a dynamic loadable-lib directory.
171///
172/// Lifecycle cards execute directly against the live `Cx` registry. Ordinary
173/// recipe cards still use the existing requires-driven catalog runner, with the
174/// same directory as their [`LibCatalog`] resolver.
175pub fn run_recipe_with_loadable_libs(
176    cx: &mut Cx,
177    directory: &LoadableLibList,
178    card: &RecipeCard,
179) -> Result<RecipeRun> {
180    if let Some((action, lib)) = lifecycle_action(card) {
181        return Ok(run_lifecycle_action(cx, directory, action, &lib, &card.id));
182    }
183    crate::run::run_recipe_with_catalog(cx, directory, card)
184}
185
186/// Execute one lifecycle command and package it as a [`RecipeRun`].
187pub fn run_lifecycle_action(
188    cx: &mut Cx,
189    directory: &LoadableLibList,
190    action: LifecycleAction,
191    lib: &str,
192    recipe: &str,
193) -> RecipeRun {
194    let result = match action {
195        LifecycleAction::Load => directory.load(cx, lib),
196        LifecycleAction::Unload => directory.unload(cx, lib),
197    };
198    lifecycle_run(recipe, result)
199}
200
201fn lifecycle_run(recipe: &str, result: Result<String>) -> RecipeRun {
202    match result {
203        Ok(message) => RecipeRun {
204            recipe: recipe.to_owned(),
205            forms: 1,
206            results: vec![message],
207            checks: Vec::new(),
208            ok: true,
209        },
210        Err(err) => RecipeRun {
211            recipe: recipe.to_owned(),
212            forms: 1,
213            results: vec![err.to_string()],
214            checks: Vec::new(),
215            ok: false,
216        },
217    }
218}
219
220/// Builds the cookbook store for the current load state and known directory.
221///
222/// A known unloaded lib contributes one synthetic load recipe. A known loaded
223/// lib contributes its embedded recipes, when available, followed by one
224/// synthetic unload recipe sorted last in that book.
225pub fn projected_recipe_store(cx: &Cx, directory: &LoadableLibList) -> Result<RecipeStore> {
226    let mut store = RecipeStore::new();
227    for entry in directory.entries() {
228        if LoadableLibList::loaded_entry_id(cx, entry).is_some() {
229            if let Some(recipes) = entry.recipes {
230                for mut card in recipes_from_embedded(recipes)
231                    .map_err(|err| Error::Eval(format!("{} recipes: {err}", entry.id)))?
232                {
233                    card.id = effective_recipe_id(&card.book, &card.id, &entry.id);
234                    card.book = entry.id.clone();
235                    card.book_title = entry.title.clone();
236                    card.book_order = entry.order;
237                    store.insert_card(card).map_err(Error::Eval)?;
238                }
239            } else {
240                store
241                    .insert_card(setup_debt_card(entry))
242                    .map_err(Error::Eval)?;
243            }
244            store.insert_card(unload_card(entry)).map_err(Error::Eval)?;
245        } else {
246            store.insert_card(load_card(entry)).map_err(Error::Eval)?;
247        }
248    }
249    Ok(store)
250}
251
252fn load_card(entry: &LoadableLibEntry) -> RecipeCard {
253    lifecycle_card(
254        format!("cookbook/load/{}", entry.id),
255        "cookbook/loadable".to_owned(),
256        entry,
257        LifecycleAction::Load,
258        format!("Load {}", entry.id),
259        0,
260        0,
261    )
262}
263
264fn effective_recipe_id(original_book: &str, original_id: &str, entry_id: &str) -> String {
265    if original_id == original_book {
266        return entry_id.to_owned();
267    }
268    if let Some(suffix) = original_id.strip_prefix(&format!("{original_book}/")) {
269        return format!("{entry_id}/{suffix}");
270    }
271    if original_id == entry_id || original_id.starts_with(&format!("{entry_id}/")) {
272        return original_id.to_owned();
273    }
274    format!("{entry_id}/{original_id}")
275}
276
277fn unload_card(entry: &LoadableLibEntry) -> RecipeCard {
278    lifecycle_card(
279        format!("{}/cookbook-lifecycle/unload", entry.id),
280        entry.id.clone(),
281        entry,
282        LifecycleAction::Unload,
283        format!("Unload {}", entry.id),
284        i64::MAX,
285        i64::MAX,
286    )
287}
288
289fn setup_debt_card(entry: &LoadableLibEntry) -> RecipeCard {
290    RecipeCard {
291        id: format!("{}/cookbook-lifecycle/setup-debt", entry.id),
292        book: entry.id.clone(),
293        chapter: "cookbook-lifecycle".to_owned(),
294        chapter_title: "Lifecycle".to_owned(),
295        chapter_summary: String::new(),
296        title: format!("Setup debt for {}", entry.id),
297        codec: "lisp".to_owned(),
298        setup: format!(
299            "(cookbook/setup-debt {:?} {:?})",
300            "missing-recipes", entry.id
301        )
302        .into_bytes(),
303        purpose: format!(
304            "`{}` is loadable in this product build and exposes no embedded cookbook directory; this descriptor keeps the gap visible.",
305            entry.id
306        ),
307        order: i64::MAX - 1,
308        chapter_order: i64::MAX - 1,
309        book_order: entry.order,
310        book_title: entry.title.clone(),
311        book_summary: String::new(),
312        tags: vec![
313            "sandbox-descriptor".to_owned(),
314            "setup-debt:missing-recipes".to_owned(),
315            format!("cookbook-lib:{}", entry.id),
316            format!("cookbook-source:{}", entry.source),
317        ],
318        requires: Vec::new(),
319        expect: Vec::new(),
320        source: RecipeSource::Crate {
321            lib: "sim/cookbook".to_owned(),
322        },
323    }
324}
325
326fn lifecycle_card(
327    id: String,
328    book: String,
329    entry: &LoadableLibEntry,
330    action: LifecycleAction,
331    title: String,
332    chapter_order: i64,
333    order: i64,
334) -> RecipeCard {
335    RecipeCard {
336        id,
337        book,
338        chapter: "cookbook-lifecycle".to_owned(),
339        chapter_title: "Lifecycle".to_owned(),
340        chapter_summary: String::new(),
341        title,
342        codec: "lisp".to_owned(),
343        setup: format!("(cookbook/{}-lib {:?})", action.as_str(), entry.id).into_bytes(),
344        purpose: format!("{} the loadable lib `{}`.", action.as_str(), entry.id),
345        order,
346        chapter_order,
347        book_order: entry.order,
348        book_title: entry.title.clone(),
349        book_summary: String::new(),
350        tags: vec![
351            format!("cookbook-action:{}", action.as_str()),
352            format!("cookbook-lib:{}", entry.id),
353            format!("cookbook-source:{}", entry.source),
354        ],
355        requires: Vec::new(),
356        expect: Vec::new(),
357        source: RecipeSource::Crate {
358            lib: "sim/cookbook".to_owned(),
359        },
360    }
361}