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};
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 used 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 used to resolve 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        if Self::is_loaded(cx, id) {
84            return Ok(format!("already loaded {id}"));
85        }
86        let entry = self
87            .entry(id)
88            .ok_or_else(|| Error::Eval(format!("unknown loadable lib `{id}`")))?;
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::loaded_id(cx, id)
100            .ok_or_else(|| Error::Eval(format!("lib `{id}` is not loaded")))?;
101        cx.unload_lib(loaded_id)?;
102        Ok(format!("unloaded {id}"))
103    }
104
105    fn loaded_id(cx: &Cx, id: &str) -> Option<LibId> {
106        let tail = id.rsplit('/').next().unwrap_or(id);
107        cx.registry()
108            .libs()
109            .iter()
110            .find(|loaded| {
111                loaded.manifest.id.as_qualified_str() == id
112                    || loaded.manifest.id.name.as_ref() == id
113                    || loaded.manifest.id.name.as_ref() == tail
114            })
115            .map(|loaded| loaded.id)
116    }
117}
118
119impl LibCatalog for LoadableLibList {
120    fn resolve(&self, name: &str) -> Option<&dyn Lib> {
121        self.entries
122            .iter()
123            .find(|entry| entry.id == name || entry.id.rsplit('/').next() == Some(name))
124            .map(|entry| entry.catalog_lib.as_ref() as &dyn Lib)
125    }
126}
127
128/// Reads the lifecycle action encoded by a synthetic cookbook card.
129pub fn lifecycle_action(card: &RecipeCard) -> Option<(LifecycleAction, String)> {
130    let action = card
131        .tags
132        .iter()
133        .find_map(|tag| tag.strip_prefix("cookbook-action:"))?;
134    let lib = card
135        .tags
136        .iter()
137        .find_map(|tag| tag.strip_prefix("cookbook-lib:"))?;
138    let action = match action {
139        "load" => LifecycleAction::Load,
140        "unload" => LifecycleAction::Unload,
141        _ => return None,
142    };
143    Some((action, lib.to_owned()))
144}
145
146/// Run a cookbook card against a dynamic loadable-lib directory.
147///
148/// Lifecycle cards execute directly against the live `Cx` registry. Ordinary
149/// recipe cards still use the existing requires-driven catalog runner, with the
150/// same directory as their [`LibCatalog`] resolver.
151pub fn run_recipe_with_loadable_libs(
152    cx: &mut Cx,
153    directory: &LoadableLibList,
154    card: &RecipeCard,
155) -> Result<RecipeRun> {
156    if let Some((action, lib)) = lifecycle_action(card) {
157        return Ok(run_lifecycle_action(cx, directory, action, &lib, &card.id));
158    }
159    crate::run::run_recipe_with_catalog(cx, directory, card)
160}
161
162/// Execute one lifecycle command and package it as a [`RecipeRun`].
163pub fn run_lifecycle_action(
164    cx: &mut Cx,
165    directory: &LoadableLibList,
166    action: LifecycleAction,
167    lib: &str,
168    recipe: &str,
169) -> RecipeRun {
170    let result = match action {
171        LifecycleAction::Load => directory.load(cx, lib),
172        LifecycleAction::Unload => directory.unload(cx, lib),
173    };
174    lifecycle_run(recipe, result)
175}
176
177fn lifecycle_run(recipe: &str, result: Result<String>) -> RecipeRun {
178    match result {
179        Ok(message) => RecipeRun {
180            recipe: recipe.to_owned(),
181            forms: 1,
182            results: vec![message],
183            checks: Vec::new(),
184            ok: true,
185        },
186        Err(err) => RecipeRun {
187            recipe: recipe.to_owned(),
188            forms: 1,
189            results: vec![err.to_string()],
190            checks: Vec::new(),
191            ok: false,
192        },
193    }
194}
195
196/// Builds the cookbook store for the current load state and known directory.
197///
198/// A known unloaded lib contributes one synthetic load recipe. A known loaded
199/// lib contributes its embedded recipes, when available, followed by one
200/// synthetic unload recipe sorted last in that book.
201pub fn projected_recipe_store(cx: &Cx, directory: &LoadableLibList) -> Result<RecipeStore> {
202    let mut store = RecipeStore::new();
203    for entry in directory.entries() {
204        if LoadableLibList::is_loaded(cx, &entry.id) {
205            if let Some(recipes) = entry.recipes {
206                for mut card in recipes_from_embedded(recipes)
207                    .map_err(|err| Error::Eval(format!("{} recipes: {err}", entry.id)))?
208                {
209                    card.book_order = entry.order;
210                    store.insert_card(card).map_err(Error::Eval)?;
211                }
212            }
213            store.insert_card(unload_card(entry)).map_err(Error::Eval)?;
214        } else {
215            store.insert_card(load_card(entry)).map_err(Error::Eval)?;
216        }
217    }
218    Ok(store)
219}
220
221fn load_card(entry: &LoadableLibEntry) -> RecipeCard {
222    lifecycle_card(
223        format!("cookbook/load/{}", entry.id),
224        "cookbook/loadable".to_owned(),
225        entry,
226        LifecycleAction::Load,
227        format!("Load {}", entry.id),
228        0,
229        0,
230    )
231}
232
233fn unload_card(entry: &LoadableLibEntry) -> RecipeCard {
234    lifecycle_card(
235        format!("{}/cookbook-lifecycle/unload", entry.id),
236        entry.id.clone(),
237        entry,
238        LifecycleAction::Unload,
239        format!("Unload {}", entry.id),
240        i64::MAX,
241        i64::MAX,
242    )
243}
244
245fn lifecycle_card(
246    id: String,
247    book: String,
248    entry: &LoadableLibEntry,
249    action: LifecycleAction,
250    title: String,
251    chapter_order: i64,
252    order: i64,
253) -> RecipeCard {
254    RecipeCard {
255        id,
256        book,
257        chapter: "cookbook-lifecycle".to_owned(),
258        chapter_title: "Lifecycle".to_owned(),
259        chapter_summary: String::new(),
260        title,
261        codec: "lisp".to_owned(),
262        setup: format!("(cookbook/{}-lib {:?})", action.as_str(), entry.id).into_bytes(),
263        purpose: format!("{} the loadable lib `{}`.", action.as_str(), entry.id),
264        order,
265        chapter_order,
266        book_order: entry.order,
267        book_title: entry.title.clone(),
268        book_summary: String::new(),
269        tags: vec![
270            format!("cookbook-action:{}", action.as_str()),
271            format!("cookbook-lib:{}", entry.id),
272            format!("cookbook-source:{}", entry.source),
273        ],
274        requires: Vec::new(),
275        expect: Vec::new(),
276        source: RecipeSource::Crate {
277            lib: "sim/cookbook".to_owned(),
278        },
279    }
280}