sim-nest 0.1.5

The SIM constellation umbrella facade: one crate that re-exports the runtime kernel, codecs, number domains, and libraries behind features.
Documentation
use std::collections::BTreeSet;
use std::sync::{Arc, Mutex};

use sim_cookbook::{RecipeCard, RecipeStore, ordered_cards, view};
use sim_kernel::{Cx, Ref, Result, Symbol, Value};
use sim_lib_cookbook::{CookbookStoreHandle, seeded_recipe_store, store_symbol};

use crate::runtime::browse::schema::card_v2_from_card_v1;

pub(crate) struct RecipeHelpEntry {
    pub(crate) symbol: Symbol,
    pub(crate) id: String,
    pub(crate) title: String,
}

pub(crate) fn catalog_symbol() -> Symbol {
    Symbol::qualified("cookbook", "catalog")
}

pub(crate) fn root_symbols() -> Vec<Symbol> {
    vec![catalog_symbol()]
}

pub(crate) fn related_recipes(cx: &Cx, subject: &Symbol) -> Result<Vec<RecipeHelpEntry>> {
    with_store(cx, |store| {
        ordered_cards(store)
            .into_iter()
            .filter(|card| recipe_matches_symbol(cx, card, subject))
            .map(|card| RecipeHelpEntry {
                symbol: recipe_symbol(&card.id),
                id: card.id.clone(),
                title: card.title.clone(),
            })
            .collect()
    })
}

pub(crate) fn card_for_symbol(cx: &mut Cx, symbol: &Symbol) -> Result<Option<Value>> {
    if symbol == &catalog_symbol() {
        return catalog_card(cx).map(Some);
    }
    if symbol.namespace.as_deref() == Some("cookbook/book") {
        let book_id = symbol.name.to_string();
        return with_store(cx, view).and_then(|cookbook| book_card(cx, &cookbook.books, &book_id));
    }
    if symbol.namespace.as_deref() == Some("cookbook/chapter") {
        let id = symbol.name.to_string();
        return with_store(cx, view).and_then(|cookbook| chapter_card(cx, &cookbook.books, &id));
    }
    if symbol.namespace.as_deref() == Some("cookbook/recipe") {
        let id = symbol.name.to_string();
        return with_store(cx, |store| store.card(&id).cloned())
            .and_then(|card| recipe_card(cx, card));
    }
    Ok(None)
}

fn catalog_card(cx: &mut Cx) -> Result<Value> {
    let see_also = with_store(cx, |store| {
        view(store)
            .books
            .iter()
            .map(|book| book_symbol(&book.id))
            .collect::<Vec<_>>()
    })?;
    card(
        cx,
        catalog_symbol(),
        Symbol::qualified("cookbook", "catalog"),
        "cookbook view over installed recipe cards",
        &["cookbook:search", "cookbook:run"],
        see_also,
    )
}

fn book_card(
    cx: &mut Cx,
    books: &[sim_cookbook::BookView],
    book_id: &str,
) -> Result<Option<Value>> {
    let Some(book) = books.iter().find(|book| book.id == book_id) else {
        return Ok(None);
    };
    let see_also = book
        .chapters
        .iter()
        .map(|chapter| chapter_symbol(&book.id, &chapter.name))
        .collect();
    let help = if book.summary.is_empty() {
        format!("cookbook book {}", book.title)
    } else {
        book.summary.clone()
    };
    card(
        cx,
        book_symbol(&book.id),
        Symbol::qualified("cookbook", "book"),
        &help,
        &["cookbook:search"],
        see_also,
    )
    .map(Some)
}

fn chapter_card(cx: &mut Cx, books: &[sim_cookbook::BookView], id: &str) -> Result<Option<Value>> {
    for book in books {
        for chapter in &book.chapters {
            if format!("{}/{}", book.id, chapter.name) == id {
                let see_also = chapter
                    .recipes
                    .iter()
                    .map(|recipe| recipe_symbol(&recipe.id))
                    .collect();
                let help = if chapter.summary.is_empty() {
                    format!("cookbook chapter {}", chapter.title)
                } else {
                    chapter.summary.clone()
                };
                return card(
                    cx,
                    chapter_symbol(&book.id, &chapter.name),
                    Symbol::qualified("cookbook", "chapter"),
                    &help,
                    &["cookbook:search"],
                    see_also,
                )
                .map(Some);
            }
        }
    }
    Ok(None)
}

fn recipe_card(cx: &mut Cx, recipe: Option<RecipeCard>) -> Result<Option<Value>> {
    let Some(recipe) = recipe else {
        return Ok(None);
    };
    let mut see_also = Vec::new();
    see_also.push(chapter_symbol(&recipe.book, &recipe.chapter));
    if let Some(next) = with_store(cx, |store| {
        sim_cookbook::next(store, &recipe.id).map(|card| recipe_symbol(&card.id))
    })? {
        see_also.push(next);
    }
    let help = format!("{}: {}", recipe.title, first_line(&recipe.purpose));
    card(
        cx,
        recipe_symbol(&recipe.id),
        Symbol::qualified("cookbook", "recipe"),
        &help,
        &["cookbook:show", "cookbook:run"],
        see_also,
    )
    .map(Some)
}

fn card(
    cx: &mut Cx,
    subject_symbol: Symbol,
    kind: Symbol,
    help: &str,
    ops: &[&str],
    see_also_symbols: Vec<Symbol>,
) -> Result<Value> {
    let subject = Ref::Symbol(subject_symbol);
    let see_also = see_also_symbols
        .into_iter()
        .map(|symbol| cx.factory().symbol(symbol))
        .collect::<Result<Vec<_>>>()?;
    let ops = ops
        .iter()
        .map(|op| cx.factory().string((*op).to_owned()))
        .collect::<Result<Vec<_>>>()?;
    let fallback = cx.factory().table(vec![
        (field("kind"), cx.factory().symbol(kind.clone())?),
        (field("help"), cx.factory().string(help.to_owned())?),
        (
            field("args"),
            cx.factory().symbol(Symbol::qualified("core", "Any"))?,
        ),
        (
            field("result"),
            cx.factory().symbol(Symbol::qualified("core", "Card"))?,
        ),
        (field("tests"), cx.factory().list(Vec::new())?),
        (field("ops"), cx.factory().list(ops)?),
        (field("requires"), cx.factory().list(Vec::new())?),
        (field("see-also"), cx.factory().list(see_also)?),
        (field("shape-known"), cx.factory().bool(true)?),
    ])?;
    let card_v1 = sim_kernel::card::card_for_ref_with_fallback(
        cx,
        subject.clone(),
        Some(fallback),
        Some(kind),
    )?;
    card_v2_from_card_v1(cx, subject, card_v1)
}

fn with_store<T>(cx: &Cx, f: impl FnOnce(&RecipeStore) -> T) -> Result<T> {
    if let Some(store) = shared_store(cx) {
        let store = store.lock().expect("recipe store lock");
        return Ok(f(&store));
    }
    let store = seeded_recipe_store()?;
    Ok(f(&store))
}

fn shared_store(cx: &Cx) -> Option<Arc<Mutex<RecipeStore>>> {
    cx.registry()
        .value_by_symbol(&store_symbol())
        .and_then(|value| value.object().downcast_ref::<CookbookStoreHandle>())
        .map(CookbookStoreHandle::store)
}

fn recipe_matches_symbol(cx: &Cx, card: &RecipeCard, subject: &Symbol) -> bool {
    let mut full_keys = BTreeSet::new();
    full_keys.insert(subject.to_string());
    if let Some(lib) = exporting_lib(cx, subject) {
        full_keys.insert(lib.to_string());
    }
    if card
        .requires
        .iter()
        .any(|required| full_keys.contains(required))
    {
        return true;
    }

    let tags = card
        .tags
        .iter()
        .map(|tag| tag.to_ascii_lowercase())
        .collect::<BTreeSet<_>>();
    full_keys
        .iter()
        .any(|key| tags.contains(&key.to_ascii_lowercase()) || parts_match(&tags, key))
}

fn exporting_lib(cx: &Cx, subject: &Symbol) -> Option<Symbol> {
    cx.registry()
        .libs()
        .iter()
        .find(|loaded| {
            loaded
                .exports
                .iter()
                .any(|export| &export.symbol == subject)
        })
        .map(|loaded| loaded.manifest.id.clone())
}

fn parts_match(tags: &BTreeSet<String>, key: &str) -> bool {
    let parts = key.split('/').collect::<Vec<_>>();
    !parts.is_empty()
        && parts
            .iter()
            .all(|part| tags.contains(&part.to_ascii_lowercase()))
}

fn first_line(text: &str) -> &str {
    text.lines()
        .map(str::trim)
        .find(|line| !line.is_empty())
        .unwrap_or("")
}

fn book_symbol(book: &str) -> Symbol {
    Symbol::qualified("cookbook/book", book)
}

fn chapter_symbol(book: &str, chapter: &str) -> Symbol {
    Symbol::qualified("cookbook/chapter", format!("{book}/{chapter}"))
}

fn recipe_symbol(id: &str) -> Symbol {
    Symbol::qualified("cookbook/recipe", id)
}

fn field(name: &str) -> Symbol {
    Symbol::new(name)
}