sim-lib-cookbook 0.1.12

Runtime cookbook: operations for SIM.
Documentation
use std::sync::Arc;

use sim_cookbook::{EmbeddedDir, RecipeStore, ordered_cards};
use sim_kernel::{
    AbiVersion, Args, Callable, ClassRef, Cx, Dependency, Export, LibManifest, LibTarget, Linker,
    LoadCx, Object, ObjectCompat, Result, Symbol, Value, Version, library::Lib,
    read_eval_capability,
};
use sim_test_support::core_cx;

use crate::{
    LibCatalog, LifecycleAction, LoadableLibEntry, LoadableLibList, lifecycle_action,
    projected_recipe_store, run_recipe_with_loadable_libs,
};

static DEMO_RECIPES: EmbeddedDir = &[
    ("book.toml", b"book = \"demo/lib\"\ntitle = \"Demo Lib\"\n"),
    (
        "01-basics/demo/recipe.toml",
        b"id = \"demo\"\ntitle = \"Demo\"\ncodec = \"lisp\"\nsetup = \"setup.siml\"\npurpose = \"purpose.md\"\n",
    ),
    ("01-basics/demo/setup.siml", b"(quote demo)"),
    ("01-basics/demo/purpose.md", b"demo recipe"),
];

struct FixtureLib {
    namespace: &'static str,
    name: &'static str,
    requires: Vec<Dependency>,
    export_probe: bool,
}

struct ProbeCallable;

impl Object for ProbeCallable {
    fn display(&self, _cx: &mut Cx) -> Result<String> {
        Ok("#<probe-callable>".to_owned())
    }

    fn as_any(&self) -> &dyn std::any::Any {
        self
    }
}

impl ObjectCompat for ProbeCallable {
    fn class(&self, cx: &mut Cx) -> Result<ClassRef> {
        cx.resolve_class(&Symbol::qualified("core", "Function"))
    }

    fn as_callable(&self) -> Option<&dyn Callable> {
        Some(self)
    }
}

impl Callable for ProbeCallable {
    fn call(&self, cx: &mut Cx, _args: Args) -> Result<Value> {
        cx.factory().nil()
    }
}

impl Lib for FixtureLib {
    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: self.requires.clone(),
            capabilities: Vec::new(),
            exports: if self.export_probe {
                vec![Export::Function {
                    symbol: probe_symbol(self.namespace, self.name),
                    function_id: None,
                }]
            } else {
                Vec::new()
            },
        }
    }

    fn load(&self, cx: &mut LoadCx, linker: &mut Linker) -> Result<()> {
        if self.export_probe {
            linker.function_value(
                probe_symbol(self.namespace, self.name),
                cx.factory().opaque(Arc::new(ProbeCallable))?,
            )?;
        }
        Ok(())
    }
}

fn fixture_lib() -> Box<dyn Lib + Send + Sync> {
    Box::new(FixtureLib {
        namespace: "demo",
        name: "lib",
        requires: Vec::new(),
        export_probe: true,
    })
}

fn dependent_lib() -> Box<dyn Lib + Send + Sync> {
    Box::new(FixtureLib {
        namespace: "demo",
        name: "consumer",
        requires: vec![Dependency {
            id: Symbol::qualified("demo", "lib"),
            minimum_version: None,
        }],
        export_probe: false,
    })
}

fn probe_symbol(namespace: &str, name: &str) -> Symbol {
    Symbol::qualified(namespace.to_owned(), format!("{name}-probe"))
}

fn directory() -> LoadableLibList {
    LoadableLibList::new(vec![LoadableLibEntry {
        id: "demo/lib".to_owned(),
        source: "symbol:demo/lib".to_owned(),
        title: "Demo Lib".to_owned(),
        order: 7,
        recipes: Some(DEMO_RECIPES),
        catalog_lib: fixture_lib(),
        factory: Arc::new(fixture_lib),
    }])
}

#[test]
fn resolves_requires_by_full_id_and_tail() {
    let directory = directory();

    assert!(directory.resolve("demo/lib").is_some());
    assert!(directory.resolve("lib").is_some());
    assert!(directory.resolve("missing").is_none());
}

#[test]
fn loaded_check_accepts_entry_tail_alias() {
    let mut cx = core_cx();
    let alias = FixtureLib {
        namespace: "sim",
        name: "binding",
        requires: Vec::new(),
        export_probe: false,
    };
    cx.load_lib(&alias).unwrap();

    assert!(LoadableLibList::is_loaded(&cx, "organ/binding"));
    assert!(LoadableLibList::is_loaded(&cx, "binding"));
    assert!(!LoadableLibList::is_loaded(&cx, "organ/control"));
}

#[test]
fn projected_store_contains_load_card_for_unloaded_lib() {
    let cx = core_cx();
    let store = projected_recipe_store(&cx, &directory()).unwrap();

    let cards = store.cards();
    assert_eq!(cards.len(), 1);
    let card = &cards[0];
    assert_eq!(card.id, "cookbook/load/demo/lib");
    assert_eq!(card.book, "cookbook/loadable");
    assert!(card.tags.contains(&"cookbook-action:load".to_owned()));
    assert_eq!(card.book_order, 7);
}

#[test]
fn lifecycle_action_reads_load_and_unload_tags() {
    let mut cx = core_cx();
    let load_store = projected_recipe_store(&cx, &directory()).unwrap();
    let load = load_store.card("cookbook/load/demo/lib").unwrap();

    assert_eq!(
        lifecycle_action(load),
        Some((LifecycleAction::Load, "demo/lib".to_owned()))
    );

    let lib = fixture_lib();
    cx.load_lib(lib.as_ref()).unwrap();
    let unload_store = projected_recipe_store(&cx, &directory()).unwrap();
    let unload = unload_store
        .card("demo/lib/cookbook-lifecycle/unload")
        .unwrap();

    assert_eq!(
        lifecycle_action(unload),
        Some((LifecycleAction::Unload, "demo/lib".to_owned()))
    );
}

#[test]
fn projected_store_contains_loaded_recipes_and_unload_card() {
    let mut cx = core_cx();
    let lib = fixture_lib();
    cx.load_lib(lib.as_ref()).unwrap();

    let store = projected_recipe_store(&cx, &directory()).unwrap();

    assert!(store.card("demo/lib/01-basics/demo").is_some());
    let unload = store.card("demo/lib/cookbook-lifecycle/unload").unwrap();
    assert_eq!(unload.order, i64::MAX);
    assert_eq!(unload.chapter_order, i64::MAX);
    assert!(unload.tags.contains(&"cookbook-action:unload".to_owned()));
    let ids: Vec<&str> = ordered_cards(&store)
        .into_iter()
        .map(|card| card.id.as_str())
        .collect();
    assert_eq!(
        ids,
        [
            "demo/lib/01-basics/demo",
            "demo/lib/cookbook-lifecycle/unload"
        ]
    );
}

#[test]
fn load_recipe_loads_known_lib() {
    let mut cx = core_cx();
    let directory = directory();
    let store = projected_recipe_store(&cx, &directory).unwrap();
    let card = store.card("cookbook/load/demo/lib").unwrap();

    let run = run_recipe_with_loadable_libs(&mut cx, &directory, card).unwrap();

    assert!(run.ok, "load run: {run:?}");
    assert_eq!(run.results, ["loaded demo/lib"]);
    assert!(LoadableLibList::is_loaded(&cx, "demo/lib"));
    assert!(
        cx.registry()
            .function_by_symbol(&probe_symbol("demo", "lib"))
            .is_some()
    );
}

#[test]
fn lifecycle_op_wrapper_uses_same_load_path() {
    let mut cx = core_cx();
    let op = crate::ops::CookbookLifecycleOp::new(Arc::new(directory()), LifecycleAction::Load);
    let lib = cx.factory().string("demo/lib".to_owned()).unwrap();

    op.call(&mut cx, Args::new(vec![lib])).unwrap();

    assert!(LoadableLibList::is_loaded(&cx, "demo/lib"));
    assert!(
        cx.registry()
            .function_by_symbol(&probe_symbol("demo", "lib"))
            .is_some()
    );
}

#[test]
fn load_recipe_is_idempotent() {
    let mut cx = core_cx();
    let directory = directory();
    let store = projected_recipe_store(&cx, &directory).unwrap();
    let card = store.card("cookbook/load/demo/lib").unwrap();

    run_recipe_with_loadable_libs(&mut cx, &directory, card).unwrap();
    let second = run_recipe_with_loadable_libs(&mut cx, &directory, card).unwrap();
    let matching = cx
        .registry()
        .libs()
        .iter()
        .filter(|loaded| loaded.manifest.id == Symbol::qualified("demo", "lib"))
        .count();

    assert!(second.ok, "second load run: {second:?}");
    assert_eq!(second.results, ["already loaded demo/lib"]);
    assert_eq!(matching, 1);
}

#[test]
fn unload_recipe_retracts_loaded_lib_exports() {
    let mut cx = core_cx();
    let directory = directory();
    let load_store = projected_recipe_store(&cx, &directory).unwrap();
    let load = load_store.card("cookbook/load/demo/lib").unwrap();
    run_recipe_with_loadable_libs(&mut cx, &directory, load).unwrap();
    let unload_store = projected_recipe_store(&cx, &directory).unwrap();
    let unload = unload_store
        .card("demo/lib/cookbook-lifecycle/unload")
        .unwrap();

    let run = run_recipe_with_loadable_libs(&mut cx, &directory, unload).unwrap();

    assert!(run.ok, "unload run: {run:?}");
    assert_eq!(run.results, ["unloaded demo/lib"]);
    assert!(!LoadableLibList::is_loaded(&cx, "demo/lib"));
    assert!(
        cx.registry()
            .function_by_symbol(&probe_symbol("demo", "lib"))
            .is_none()
    );
}

#[test]
fn unload_recipe_refuses_dependents() {
    let mut cx = core_cx();
    let directory = directory();
    let base = fixture_lib();
    let dependent = dependent_lib();
    cx.load_lib(base.as_ref()).unwrap();
    cx.load_lib(dependent.as_ref()).unwrap();
    let store = projected_recipe_store(&cx, &directory).unwrap();
    let unload = store.card("demo/lib/cookbook-lifecycle/unload").unwrap();

    let run = run_recipe_with_loadable_libs(&mut cx, &directory, unload).unwrap();

    assert!(!run.ok, "dependent unload should fail: {run:?}");
    assert!(run.results[0].contains("cannot unload demo/lib"));
    assert!(run.results[0].contains("consumer"));
    assert!(LoadableLibList::is_loaded(&cx, "demo/lib"));
    assert!(LoadableLibList::is_loaded(&cx, "demo/consumer"));
}

#[test]
fn ordinary_recipe_still_runs_through_catalog() {
    let mut cx = core_cx();
    let lisp = sim_codec_lisp::LispCodecLib::new(cx.registry_mut().fresh_codec_id()).unwrap();
    cx.load_lib(&lisp).unwrap();
    cx.grant(read_eval_capability());
    let mut store = RecipeStore::new();
    store.register_book(DEMO_RECIPES).unwrap();
    let card = store.card("demo/lib/01-basics/demo").unwrap();
    let directory = directory();

    let run = run_recipe_with_loadable_libs(&mut cx, &directory, card).unwrap();

    assert!(run.ok, "ordinary recipe run: {run:?}");
    assert_eq!(run.results, ["demo"]);
    assert!(LoadableLibList::is_loaded(&cx, "demo/lib"));
}