use std::path::Path;
use crate::embed::recipes_from_embedded;
use crate::model::RecipeSource;
use crate::store::RecipeStore;
use crate::toml_lite;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct OverlayDirectives {
pub reorder: Vec<(String, i64)>,
pub hide: Vec<String>,
pub retitle: Vec<(String, String)>,
}
fn table_str(table: &[(String, toml_lite::TomlValue)], key: &str) -> Result<String, String> {
table
.iter()
.find(|(k, _)| k == key)
.ok_or_else(|| format!("`[[..]]` table missing `{key}`"))?
.1
.as_str()
.map(str::to_string)
.map_err(|e| format!("`{key}`: {e}"))
}
fn table_int(table: &[(String, toml_lite::TomlValue)], key: &str) -> Result<i64, String> {
table
.iter()
.find(|(k, _)| k == key)
.ok_or_else(|| format!("`[[..]]` table missing `{key}`"))?
.1
.as_int()
.map_err(|e| format!("`{key}`: {e}"))
}
pub fn parse_overlay(text: &str) -> Result<OverlayDirectives, String> {
let doc = toml_lite::parse(text)?;
doc.reject_unknown_top(&[])?;
doc.reject_unknown_tables(&["reorder", "hide", "retitle"])?;
let mut directives = OverlayDirectives::default();
for table in doc.tables_named("reorder") {
directives
.reorder
.push((table_str(table, "recipe")?, table_int(table, "order")?));
}
for table in doc.tables_named("hide") {
directives.hide.push(table_str(table, "recipe")?);
}
for table in doc.tables_named("retitle") {
directives
.retitle
.push((table_str(table, "recipe")?, table_str(table, "title")?));
}
Ok(directives)
}
pub fn apply_directives(store: &mut RecipeStore, directives: &OverlayDirectives) {
for id in &directives.hide {
store.remove(id);
}
for (id, order) in &directives.reorder {
if let Some(card) = store.card_mut(id) {
card.order = *order;
}
}
for (id, title) in &directives.retitle {
if let Some(card) = store.card_mut(id) {
card.title = title.clone();
}
}
}
fn read_tree(root: &Path) -> Result<Vec<(String, Vec<u8>)>, String> {
if !root.join("book.toml").is_file() {
return Ok(Vec::new());
}
let mut files = Vec::new();
collect(root, root, &mut files)?;
Ok(files)
}
fn collect(base: &Path, dir: &Path, out: &mut Vec<(String, Vec<u8>)>) -> Result<(), String> {
let entries = std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let mut entries: Vec<_> = entries
.collect::<std::io::Result<Vec<_>>>()
.map_err(|e| e.to_string())?;
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let path = entry.path();
if path.is_dir() {
collect(base, &path, out)?;
} else if path.is_file() {
let rel = path
.strip_prefix(base)
.map_err(|e| e.to_string())?
.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/");
if rel == "overlay.toml" {
continue;
}
let bytes = std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?;
out.push((rel, bytes));
}
}
Ok(())
}
pub fn load_overlay(store: &mut RecipeStore, root: &Path) -> Result<(), String> {
if !root.is_dir() {
return Ok(());
}
let owned = read_tree(root)?;
if !owned.is_empty() {
let borrowed: Vec<(&str, &[u8])> = owned
.iter()
.map(|(p, b)| (p.as_str(), b.as_slice()))
.collect();
let path = root.display().to_string();
for mut card in recipes_from_embedded(&borrowed)? {
card.source = RecipeSource::Overlay { path: path.clone() };
store.upsert_card(card);
}
}
let overlay_toml = root.join("overlay.toml");
if overlay_toml.is_file() {
let text = std::fs::read_to_string(&overlay_toml).map_err(|e| e.to_string())?;
let directives = parse_overlay(&text)?;
apply_directives(store, &directives);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::project::view;
fn base_store() -> RecipeStore {
let alpha: Vec<(&str, &[u8])> = vec![
(
"book.toml",
b"book = \"alpha\"\ntitle = \"Alpha\"\norder = 100\n" as &[u8],
),
(
"01-basics/add/recipe.toml",
b"id = \"add\"\ntitle = \"Add\"\ncodec = \"lisp\"\nsetup = \"s\"\npurpose = \"p\"\norder = 100\n",
),
("01-basics/add/s", b"(+ 1 2)"),
("01-basics/add/p", b"add"),
(
"01-basics/sub/recipe.toml",
b"id = \"sub\"\ntitle = \"Subtract\"\ncodec = \"lisp\"\nsetup = \"s\"\npurpose = \"p\"\norder = 200\n",
),
("01-basics/sub/s", b"(- 3 1)"),
("01-basics/sub/p", b"sub"),
];
let mut store = RecipeStore::new();
store.register_book(&alpha).unwrap();
store
}
#[test]
fn parse_overlay_reads_all_directives() {
let d = parse_overlay(
"[[reorder]]\nrecipe = \"alpha/01-basics/sub\"\norder = 1\n[[hide]]\nrecipe = \"alpha/01-basics/add\"\n[[retitle]]\nrecipe = \"alpha/01-basics/sub\"\ntitle = \"Minus\"\n",
)
.unwrap();
assert_eq!(d.reorder, [("alpha/01-basics/sub".to_string(), 1)]);
assert_eq!(d.hide, ["alpha/01-basics/add"]);
assert_eq!(
d.retitle,
[("alpha/01-basics/sub".to_string(), "Minus".to_string())]
);
}
#[test]
fn hide_reorder_retitle_apply() {
let mut store = base_store();
let directives = OverlayDirectives {
reorder: vec![("alpha/01-basics/sub".to_string(), 1)],
hide: vec!["alpha/01-basics/add".to_string()],
retitle: vec![("alpha/01-basics/sub".to_string(), "Minus".to_string())],
};
apply_directives(&mut store, &directives);
assert!(store.card("alpha/01-basics/add").is_none()); let sub = store.card("alpha/01-basics/sub").unwrap();
assert_eq!(sub.order, 1); assert_eq!(sub.title, "Minus"); }
fn temp_dir(tag: &str) -> std::path::PathBuf {
let dir =
std::env::temp_dir().join(format!("sim-cb-overlay-{}-{}", std::process::id(), tag));
let _ = std::fs::remove_dir_all(&dir);
dir
}
#[test]
fn overlay_adds_to_foreign_book_and_applies_directives() {
let root = temp_dir("foreign");
std::fs::create_dir_all(root.join("99-extra/mul")).unwrap();
std::fs::write(
root.join("book.toml"),
b"book = \"alpha\"\ntitle = \"Alpha\"\norder = 100\n",
)
.unwrap();
std::fs::write(
root.join("99-extra/mul/recipe.toml"),
b"id = \"mul\"\ntitle = \"Multiply\"\ncodec = \"lisp\"\nsetup = \"s\"\npurpose = \"p\"\norder = 100\n",
)
.unwrap();
std::fs::write(root.join("99-extra/mul/s"), b"(* 2 3)").unwrap();
std::fs::write(root.join("99-extra/mul/p"), b"multiply").unwrap();
std::fs::write(
root.join("overlay.toml"),
b"[[hide]]\nrecipe = \"alpha/01-basics/sub\"\n",
)
.unwrap();
let mut store = base_store();
load_overlay(&mut store, &root).unwrap();
let mul = store.card("alpha/99-extra/mul").unwrap();
assert_eq!(
mul.source,
RecipeSource::Overlay {
path: root.display().to_string()
}
);
assert!(store.card("alpha/01-basics/sub").is_none());
let view = view(&store);
assert_eq!(view.books.len(), 1);
let chapters: Vec<&str> = view.books[0]
.chapters
.iter()
.map(|c| c.name.as_str())
.collect();
assert_eq!(chapters, ["01-basics", "99-extra"]);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn overlay_recipe_overrides_crate_recipe_by_id() {
let root = temp_dir("override");
std::fs::create_dir_all(root.join("01-basics/add")).unwrap();
std::fs::write(
root.join("book.toml"),
b"book = \"alpha\"\ntitle = \"Alpha\"\norder = 100\n",
)
.unwrap();
std::fs::write(
root.join("01-basics/add/recipe.toml"),
b"id = \"add\"\ntitle = \"Overridden Add\"\ncodec = \"lisp\"\nsetup = \"s\"\npurpose = \"p\"\n",
)
.unwrap();
std::fs::write(root.join("01-basics/add/s"), b"(+ 9 9)").unwrap();
std::fs::write(root.join("01-basics/add/p"), b"overridden").unwrap();
let mut store = base_store();
assert_eq!(store.len(), 2);
load_overlay(&mut store, &root).unwrap();
assert_eq!(store.len(), 2); assert_eq!(
store.card("alpha/01-basics/add").unwrap().title,
"Overridden Add"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn missing_overlay_dir_is_noop() {
let mut store = base_store();
load_overlay(&mut store, Path::new("/nonexistent/sim/cookbook")).unwrap();
assert_eq!(store.len(), 2);
}
}