Skip to main content

sim_lib_cookbook/
config.rs

1//! Config-shaped provider for a host-owned cookbook loadable-lib directory.
2
3use std::collections::HashSet;
4
5use sim_config::{ConfigView, EffectiveConfig};
6use sim_cookbook::EmbeddedDir;
7use sim_kernel::{Expr, Symbol};
8use sim_value::access::field_any;
9
10use crate::loadable::{LibFactory, LoadableLibEntry, LoadableLibList};
11
12/// In-memory shape of the `sim/cookbook` config table.
13#[derive(Clone, Debug, Default, PartialEq, Eq)]
14pub struct CookbookConfig {
15    /// Host boot set that a caller may load separately.
16    pub minimum_loaded: Vec<String>,
17    /// Ordered effective directory of loadable libs to expose in the cookbook.
18    pub loadable_libs: Vec<LoadableLibConfig>,
19}
20
21/// Overlay parsed from the `sim/cookbook` effective config table.
22#[derive(Clone, Debug, Default, PartialEq, Eq)]
23pub struct CookbookOverrides {
24    /// Replacement host boot set, when the config explicitly names it.
25    pub minimum_loaded: Option<Vec<String>>,
26    /// Rows to add to, or replace in, the base loadable-lib directory.
27    pub add: Vec<LoadableLibConfig>,
28    /// Row ids hidden from the base loadable-lib directory.
29    pub hide: Vec<String>,
30    /// Preferred display order for row ids. Unmentioned rows keep relative order.
31    pub order: Vec<String>,
32}
33
34/// One configured loadable-lib row.
35#[derive(Clone, Debug, PartialEq, Eq)]
36pub struct LoadableLibConfig {
37    /// Cookbook-facing library id, such as `numbers/cas`.
38    pub id: String,
39    /// Host resolver key, such as `symbol:numbers/cas`.
40    pub source: String,
41}
42
43/// Host-resolved loadable-lib material.
44pub struct ResolvedLoadable {
45    /// Human title used for this library's cookbook book.
46    pub title: String,
47    /// Embedded recipes for this lib, when the host can expose them.
48    pub recipes: Option<EmbeddedDir>,
49    /// Factory used to build fresh lib instances.
50    pub factory: LibFactory,
51}
52
53/// Host resolver for config-selected loadable libs.
54pub trait LoadableLibResolver {
55    /// Resolves one config row by source key and cookbook id.
56    fn resolve(&self, source: &str, id: &str) -> Option<ResolvedLoadable>;
57}
58
59/// Converts a [`CookbookConfig`] into an effective loadable-lib directory.
60pub struct ConfigProvider<'a, R: LoadableLibResolver + ?Sized> {
61    config: CookbookConfig,
62    resolver: &'a R,
63}
64
65impl<'a, R: LoadableLibResolver + ?Sized> ConfigProvider<'a, R> {
66    /// Creates a provider over one config snapshot and host resolver.
67    pub fn new(config: CookbookConfig, resolver: &'a R) -> Self {
68        Self { config, resolver }
69    }
70
71    /// Returns the configured minimum boot set without loading it.
72    pub fn minimum_loaded(&self) -> &[String] {
73        &self.config.minimum_loaded
74    }
75
76    /// Returns the in-memory cookbook config snapshot.
77    pub fn config(&self) -> &CookbookConfig {
78        &self.config
79    }
80
81    /// Resolves the configured loadable-lib directory.
82    ///
83    /// The config array order is the resulting display order. Unknown sources
84    /// and duplicate ids are reported as diagnostics and skipped.
85    pub fn loadable_libs(&self) -> (LoadableLibList, Vec<String>) {
86        let mut entries = Vec::new();
87        let mut diagnostics = Vec::new();
88        let mut seen = HashSet::new();
89
90        for (index, cfg) in self.config.loadable_libs.iter().enumerate() {
91            if !seen.insert(cfg.id.as_str()) {
92                diagnostics.push(format!("duplicate loadable-lib id `{}`", cfg.id));
93                continue;
94            }
95
96            match self.resolver.resolve(&cfg.source, &cfg.id) {
97                Some(resolved) => entries.push(LoadableLibEntry {
98                    id: cfg.id.clone(),
99                    source: cfg.source.clone(),
100                    title: resolved.title,
101                    order: index as i64,
102                    recipes: resolved.recipes,
103                    catalog_lib: (resolved.factory)(),
104                    factory: resolved.factory,
105                }),
106                None => diagnostics.push(format!(
107                    "unknown loadable-lib source `{}` for `{}`",
108                    cfg.source, cfg.id
109                )),
110            }
111        }
112
113        (LoadableLibList::new(entries), diagnostics)
114    }
115}
116
117/// Cookbook config provider backed by a merged effective config Dir.
118pub struct ConfigCookbookProvider<'a, R: LoadableLibResolver + ?Sized> {
119    inner: ConfigProvider<'a, R>,
120}
121
122impl<'a, R: LoadableLibResolver + ?Sized> ConfigCookbookProvider<'a, R> {
123    /// Reads the effective `sim/cookbook` table and prepares a directory
124    /// provider over `resolver`.
125    pub fn new(effective: &EffectiveConfig, resolver: &'a R) -> Self {
126        Self::new_with_base(effective, built_in_config(), resolver)
127    }
128
129    /// Reads the effective `sim/cookbook` table as an overlay over `base`.
130    pub fn new_with_base(
131        effective: &EffectiveConfig,
132        base: CookbookConfig,
133        resolver: &'a R,
134    ) -> Self {
135        Self {
136            inner: ConfigProvider::new(
137                cookbook_config_from_effective_with_base(effective, base),
138                resolver,
139            ),
140        }
141    }
142
143    /// Returns the effective cookbook config snapshot.
144    pub fn config(&self) -> &CookbookConfig {
145        self.inner.config()
146    }
147
148    /// Returns the configured minimum boot set without loading it.
149    pub fn minimum_loaded(&self) -> &[String] {
150        self.inner.minimum_loaded()
151    }
152
153    /// Resolves the configured loadable-lib directory.
154    pub fn loadable_libs(&self) -> (LoadableLibList, Vec<String>) {
155        self.inner.loadable_libs()
156    }
157}
158
159/// Returns the stable config library id for cookbook defaults.
160pub fn cookbook_lib_symbol() -> Symbol {
161    Symbol::qualified("sim", "cookbook")
162}
163
164/// Builds the in-memory cookbook directory config from an effective Dir.
165///
166/// When no `sim/cookbook` table is present, the seeded built-in directory is
167/// used. When a table is present, it overlays the base directory so a host or
168/// user config can add, hide, or reorder loadable libs without loading them.
169pub fn cookbook_config_from_effective(effective: &EffectiveConfig) -> CookbookConfig {
170    cookbook_config_from_effective_with_base(effective, built_in_config())
171}
172
173/// Builds the in-memory cookbook directory by applying effective overrides over
174/// a host-provided base directory.
175pub fn cookbook_config_from_effective_with_base(
176    effective: &EffectiveConfig,
177    base: CookbookConfig,
178) -> CookbookConfig {
179    apply_overrides(base, &cookbook_overrides_from_effective(effective))
180}
181
182/// Parses the effective `sim/cookbook` table as a directory overlay.
183pub fn cookbook_overrides_from_effective(effective: &EffectiveConfig) -> CookbookOverrides {
184    let Some(table) = effective.dir.table(&cookbook_lib_symbol()) else {
185        return CookbookOverrides::default();
186    };
187    let view = ConfigView::new(table);
188    CookbookOverrides {
189        minimum_loaded: optional_string_array(&view, "minimum_loaded"),
190        add: loadable_lib_rows(&view),
191        hide: string_array(&view, "hide"),
192        order: string_array(&view, "order"),
193    }
194}
195
196/// Applies a cookbook config overlay over an existing directory snapshot.
197pub fn apply_overrides(base: CookbookConfig, overrides: &CookbookOverrides) -> CookbookConfig {
198    let CookbookConfig {
199        minimum_loaded,
200        loadable_libs,
201    } = base;
202    let minimum_loaded = overrides.minimum_loaded.clone().unwrap_or(minimum_loaded);
203    let mut rows: Vec<LoadableLibConfig> = loadable_libs
204        .into_iter()
205        .filter(|row| !overrides.hide.contains(&row.id))
206        .collect();
207    for add in &overrides.add {
208        match rows.iter_mut().find(|row| row.id == add.id) {
209            Some(row) => *row = add.clone(),
210            None => rows.push(add.clone()),
211        }
212    }
213    if !overrides.order.is_empty() {
214        let mut indexed = rows.into_iter().enumerate().collect::<Vec<_>>();
215        indexed.sort_by_key(|(index, row)| {
216            (
217                overrides
218                    .order
219                    .iter()
220                    .position(|id| id == &row.id)
221                    .unwrap_or(usize::MAX),
222                *index,
223            )
224        });
225        rows = indexed.into_iter().map(|(_, row)| row).collect();
226    }
227    CookbookConfig {
228        minimum_loaded,
229        loadable_libs: rows,
230    }
231}
232
233/// Built-in cookbook directory config used by the seeded host resolver.
234pub fn built_in_config() -> CookbookConfig {
235    CookbookConfig {
236        minimum_loaded: vec!["codec/lisp".to_owned()],
237        loadable_libs: vec![
238            loadable("numbers/i64"),
239            loadable("numbers/arith"),
240            loadable("numbers/bigint"),
241            loadable("numbers/bool"),
242            loadable("numbers/f64"),
243            loadable("numbers/rational"),
244            loadable("numbers/complex"),
245            loadable("numbers/func"),
246            loadable("numbers/cas"),
247            loadable("numbers/tensor"),
248            loadable("numbers/tensor-bcast"),
249            loadable("discrete"),
250            loadable("organ/binding"),
251            loadable("organ/control"),
252            loadable("organ/sequence"),
253            loadable("organ/pattern"),
254            loadable("codec/algol"),
255            loadable("codec/scheme-r7rs-small"),
256            loadable("midi/digest"),
257        ],
258    }
259}
260
261fn loadable(id: &str) -> LoadableLibConfig {
262    LoadableLibConfig {
263        id: id.to_owned(),
264        source: format!("symbol:{id}"),
265    }
266}
267
268fn string_array(view: &ConfigView<'_>, key: &str) -> Vec<String> {
269    view.list(key)
270        .unwrap_or_default()
271        .iter()
272        .filter_map(string_value)
273        .collect()
274}
275
276fn optional_string_array(view: &ConfigView<'_>, key: &str) -> Option<Vec<String>> {
277    view.get(key).map(|value| match value {
278        Expr::List(items) => items.iter().filter_map(string_value).collect(),
279        _ => Vec::new(),
280    })
281}
282
283fn string_value(item: &Expr) -> Option<String> {
284    match item {
285        Expr::String(value) => Some(value.clone()),
286        _ => None,
287    }
288}
289
290fn loadable_lib_rows(view: &ConfigView<'_>) -> Vec<LoadableLibConfig> {
291    view.list("loadable_lib")
292        .unwrap_or_default()
293        .iter()
294        .map(|entry| LoadableLibConfig {
295            id: string_at(entry, "id").unwrap_or_default().to_owned(),
296            source: string_at(entry, "source").unwrap_or_default().to_owned(),
297        })
298        .collect()
299}
300
301fn string_at<'a>(entry: &'a Expr, key: &str) -> Option<&'a str> {
302    field_any(entry, key).and_then(|value| match value {
303        Expr::String(text) => Some(text.as_str()),
304        _ => None,
305    })
306}