sim-lib-cookbook 0.1.13

Runtime cookbook: operations for SIM.
Documentation
use std::{path::PathBuf, sync::Arc};

use sim_config::{ConfigDir, ConfigLayer, ConfigSource, merge_layers};
use sim_cookbook::{EmbeddedDir, ordered_cards};
use sim_kernel::{
    AbiVersion, Expr, LibManifest, LibTarget, Linker, LoadCx, Result, Symbol, Version, library::Lib,
};
use sim_test_support::core_cx;

use crate::{
    ConfigCookbookProvider, ConfigProvider, CookbookConfig, LibCatalog, LoadableLibConfig,
    LoadableLibList, LoadableLibResolver, ResolvedLoadable,
    cookbook_config_from_effective_with_base, cookbook_lib_symbol, projected_recipe_store,
};

struct NamedLib {
    namespace: &'static str,
    name: &'static str,
}

impl Lib for NamedLib {
    fn manifest(&self) -> LibManifest {
        LibManifest {
            id: Symbol::qualified(self.namespace, self.name),
            version: Version("0.1.0".to_owned()),
            abi: AbiVersion { major: 0, minor: 1 },
            target: LibTarget::HostRegistered,
            requires: Vec::new(),
            capabilities: Vec::new(),
            exports: Vec::new(),
        }
    }

    fn load(&self, _cx: &mut LoadCx, _linker: &mut Linker) -> Result<()> {
        Ok(())
    }
}

struct FixtureResolver;

impl LoadableLibResolver for FixtureResolver {
    fn resolve(&self, source: &str, _id: &str) -> Option<ResolvedLoadable> {
        match source {
            "symbol:demo/alpha" => Some(resolved("Alpha", None, || {
                Box::new(NamedLib {
                    namespace: "demo",
                    name: "alpha",
                })
            })),
            "symbol:demo/beta" => Some(resolved("Beta", None, || {
                Box::new(NamedLib {
                    namespace: "demo",
                    name: "beta",
                })
            })),
            _ => None,
        }
    }
}

fn resolved<F>(title: &str, recipes: Option<EmbeddedDir>, make: F) -> ResolvedLoadable
where
    F: Fn() -> Box<dyn Lib + Send + Sync> + Send + Sync + 'static,
{
    ResolvedLoadable {
        title: title.to_owned(),
        recipes,
        factory: Arc::new(make),
    }
}

fn row(id: &str) -> LoadableLibConfig {
    LoadableLibConfig {
        id: id.to_owned(),
        source: format!("symbol:{id}"),
    }
}

#[test]
fn config_can_subset_and_reorder() {
    let config = CookbookConfig {
        minimum_loaded: Vec::new(),
        loadable_libs: vec![row("demo/beta"), row("demo/alpha")],
    };
    let provider = ConfigProvider::new(config, &FixtureResolver);

    let (directory, diagnostics) = provider.loadable_libs();

    assert!(diagnostics.is_empty(), "{diagnostics:?}");
    let ids: Vec<_> = directory
        .entries()
        .iter()
        .map(|entry| entry.id.as_str())
        .collect();
    assert_eq!(ids, ["demo/beta", "demo/alpha"]);
    assert_eq!(directory.entry("demo/beta").unwrap().order, 0);
    assert_eq!(directory.entry("demo/alpha").unwrap().order, 1);
    assert!(directory.resolve("beta").is_some());
    assert!(directory.resolve("alpha").is_some());
}

#[test]
fn unknown_source_and_duplicate_ids_are_diagnostics() {
    let config = CookbookConfig {
        minimum_loaded: Vec::new(),
        loadable_libs: vec![
            row("demo/alpha"),
            LoadableLibConfig {
                id: "demo/missing".to_owned(),
                source: "symbol:demo/missing".to_owned(),
            },
            LoadableLibConfig {
                id: "demo/alpha".to_owned(),
                source: "symbol:demo/beta".to_owned(),
            },
        ],
    };
    let provider = ConfigProvider::new(config, &FixtureResolver);

    let (directory, diagnostics) = provider.loadable_libs();

    assert_eq!(directory.entries().len(), 1);
    assert_eq!(directory.entries()[0].id, "demo/alpha");
    assert_eq!(diagnostics.len(), 2);
    assert!(
        diagnostics.iter().any(|diagnostic| diagnostic
            == "unknown loadable-lib source `symbol:demo/missing` for `demo/missing`"),
        "{diagnostics:?}"
    );
    assert!(
        diagnostics
            .iter()
            .any(|diagnostic| diagnostic == "duplicate loadable-lib id `demo/alpha`"),
        "{diagnostics:?}"
    );
}

#[test]
fn minimum_loaded_does_not_force_load() {
    let config = CookbookConfig {
        minimum_loaded: vec!["demo/alpha".to_owned()],
        loadable_libs: vec![row("demo/alpha")],
    };
    let provider = ConfigProvider::new(config, &FixtureResolver);
    let cx = core_cx();

    assert_eq!(provider.minimum_loaded(), ["demo/alpha"]);
    assert!(!LoadableLibList::is_loaded(&cx, "demo/alpha"));
}

#[test]
fn effective_config_drives_loadable_directory_without_loading() {
    let base = CookbookConfig {
        minimum_loaded: vec!["codec/lisp".to_owned()],
        loadable_libs: vec![row("demo/alpha")],
    };
    let effective = merge_layers(&[ConfigLayer::new(
        ConfigSource::Explicit {
            label: "work".to_owned(),
        },
        ConfigDir::one(
            cookbook_lib_symbol(),
            map(vec![
                ("minimum_loaded", list(vec![text("demo/alpha")])),
                ("order", list(vec![text("demo/beta"), text("demo/alpha")])),
                (
                    "loadable_lib",
                    list(vec![map(vec![
                        ("id", text("demo/beta")),
                        ("source", text("symbol:demo/beta")),
                    ])]),
                ),
            ]),
        )
        .unwrap(),
    )]);
    let provider = ConfigCookbookProvider::new_with_base(&effective, base, &FixtureResolver);
    let cx = core_cx();

    let (directory, diagnostics) = provider.loadable_libs();

    assert_eq!(provider.minimum_loaded(), ["demo/alpha"]);
    assert!(diagnostics.is_empty(), "{diagnostics:?}");
    assert_eq!(ids(&directory), ["demo/beta", "demo/alpha"]);
    assert!(!LoadableLibList::is_loaded(&cx, "demo/alpha"));
    assert!(!LoadableLibList::is_loaded(&cx, "demo/beta"));
}

#[test]
fn home_work_config_can_hide_subset_and_reorder_cookbook_libs() {
    let base = CookbookConfig {
        minimum_loaded: vec!["codec/lisp".to_owned()],
        loadable_libs: vec![row("demo/alpha"), row("demo/hidden")],
    };
    let home = ConfigLayer::new(
        ConfigSource::HomeFile {
            path: PathBuf::from("/tmp/home/libs/sim/cookbook.toml"),
        },
        ConfigDir::one(
            cookbook_lib_symbol(),
            map(vec![("minimum_loaded", list(vec![text("codec/lisp")]))]),
        )
        .unwrap(),
    );
    let work = ConfigLayer::new(
        ConfigSource::WorkFile {
            path: PathBuf::from("/tmp/work/libs/sim/cookbook.toml"),
        },
        ConfigDir::one(
            cookbook_lib_symbol(),
            map(vec![
                ("hide", list(vec![text("demo/hidden")])),
                ("order", list(vec![text("demo/beta"), text("demo/alpha")])),
                (
                    "loadable_lib",
                    list(vec![map(vec![
                        ("id", text("demo/beta")),
                        ("source", text("symbol:demo/beta")),
                    ])]),
                ),
            ]),
        )
        .unwrap(),
    );
    let effective = merge_layers(&[home, work]);

    let config = cookbook_config_from_effective_with_base(&effective, base);
    let provider = ConfigProvider::new(config, &FixtureResolver);
    let cx = core_cx();
    let (directory, diagnostics) = provider.loadable_libs();

    assert_eq!(provider.minimum_loaded(), ["codec/lisp"]);
    assert!(diagnostics.is_empty(), "{diagnostics:?}");
    assert_eq!(ids(&directory), ["demo/beta", "demo/alpha"]);
    assert!(directory.entry("demo/hidden").is_none());
    assert!(!LoadableLibList::is_loaded(&cx, "demo/alpha"));
    assert!(!LoadableLibList::is_loaded(&cx, "demo/beta"));

    let store = projected_recipe_store(&cx, &directory).unwrap();
    assert!(store.card("cookbook/load/demo/hidden").is_none());
    let cards = ordered_cards(&store);
    assert_eq!(
        cards
            .iter()
            .filter(|card| card.id == "cookbook/load/demo/beta")
            .count(),
        1
    );
    let load_ids: Vec<_> = cards
        .into_iter()
        .filter_map(|card| card.id.strip_prefix("cookbook/load/"))
        .collect();
    assert_eq!(load_ids, ["demo/beta", "demo/alpha"]);
}

#[cfg(feature = "seed-recipes")]
#[test]
fn built_in_provider_produces_seeded_directory() {
    let resolver = crate::BuiltInLoadableResolver;
    let provider = ConfigProvider::new(crate::built_in_config(), &resolver);

    let (from_config, diagnostics) = provider.loadable_libs();
    let seeded = crate::SeededLibCatalog::loadable_libs();

    assert!(diagnostics.is_empty(), "{diagnostics:?}");
    assert_eq!(provider.minimum_loaded(), ["codec/lisp"]);
    assert_eq!(ids(&from_config), ids(&seeded));
    assert!(from_config.entry("numbers/cas").is_some());
    assert!(from_config.entry("codec/algol").is_some());
}

fn ids(directory: &LoadableLibList) -> Vec<&str> {
    directory
        .entries()
        .iter()
        .map(|entry| entry.id.as_str())
        .collect()
}

fn text(value: &str) -> Expr {
    Expr::String(value.to_owned())
}

fn list(items: Vec<Expr>) -> Expr {
    Expr::List(items)
}

fn map(entries: Vec<(&str, Expr)>) -> Expr {
    Expr::Map(
        entries
            .into_iter()
            .map(|(key, value)| (Expr::Symbol(Symbol::new(key)), value))
            .collect(),
    )
}