1use std::path::Path;
15
16use crate::embed::recipes_from_embedded;
17use crate::model::RecipeSource;
18use crate::store::RecipeStore;
19use crate::toml_lite;
20
21#[derive(Clone, Debug, Default, PartialEq, Eq)]
23pub struct OverlayDirectives {
24 pub reorder: Vec<(String, i64)>,
26 pub hide: Vec<String>,
28 pub retitle: Vec<(String, String)>,
30}
31
32fn table_str(table: &[(String, toml_lite::TomlValue)], key: &str) -> Result<String, String> {
33 table
34 .iter()
35 .find(|(k, _)| k == key)
36 .ok_or_else(|| format!("`[[..]]` table missing `{key}`"))?
37 .1
38 .as_str()
39 .map(str::to_string)
40 .map_err(|e| format!("`{key}`: {e}"))
41}
42
43fn table_int(table: &[(String, toml_lite::TomlValue)], key: &str) -> Result<i64, String> {
44 table
45 .iter()
46 .find(|(k, _)| k == key)
47 .ok_or_else(|| format!("`[[..]]` table missing `{key}`"))?
48 .1
49 .as_int()
50 .map_err(|e| format!("`{key}`: {e}"))
51}
52
53pub fn parse_overlay(text: &str) -> Result<OverlayDirectives, String> {
55 let doc = toml_lite::parse(text)?;
56 doc.reject_unknown_top(&[])?;
57 doc.reject_unknown_tables(&["reorder", "hide", "retitle"])?;
58 let mut directives = OverlayDirectives::default();
59 for table in doc.tables_named("reorder") {
60 directives
61 .reorder
62 .push((table_str(table, "recipe")?, table_int(table, "order")?));
63 }
64 for table in doc.tables_named("hide") {
65 directives.hide.push(table_str(table, "recipe")?);
66 }
67 for table in doc.tables_named("retitle") {
68 directives
69 .retitle
70 .push((table_str(table, "recipe")?, table_str(table, "title")?));
71 }
72 Ok(directives)
73}
74
75pub fn apply_directives(store: &mut RecipeStore, directives: &OverlayDirectives) {
79 for id in &directives.hide {
80 store.remove(id);
81 }
82 for (id, order) in &directives.reorder {
83 if let Some(card) = store.card_mut(id) {
84 card.order = *order;
85 }
86 }
87 for (id, title) in &directives.retitle {
88 if let Some(card) = store.card_mut(id) {
89 card.title = title.clone();
90 }
91 }
92}
93
94fn read_tree(root: &Path) -> Result<Vec<(String, Vec<u8>)>, String> {
97 if !root.join("book.toml").is_file() {
98 return Ok(Vec::new());
99 }
100 let mut files = Vec::new();
101 collect(root, root, &mut files)?;
102 Ok(files)
103}
104
105fn collect(base: &Path, dir: &Path, out: &mut Vec<(String, Vec<u8>)>) -> Result<(), String> {
106 let entries = std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
107 let mut entries: Vec<_> = entries
108 .collect::<std::io::Result<Vec<_>>>()
109 .map_err(|e| e.to_string())?;
110 entries.sort_by_key(|e| e.file_name());
111 for entry in entries {
112 let path = entry.path();
113 if path.is_dir() {
114 collect(base, &path, out)?;
115 } else if path.is_file() {
116 let rel = path
117 .strip_prefix(base)
118 .map_err(|e| e.to_string())?
119 .components()
120 .map(|c| c.as_os_str().to_string_lossy())
121 .collect::<Vec<_>>()
122 .join("/");
123 if rel == "overlay.toml" {
124 continue;
125 }
126 let bytes = std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?;
127 out.push((rel, bytes));
128 }
129 }
130 Ok(())
131}
132
133pub fn load_overlay(store: &mut RecipeStore, root: &Path) -> Result<(), String> {
137 if !root.is_dir() {
138 return Ok(());
139 }
140 let owned = read_tree(root)?;
141 if !owned.is_empty() {
142 let borrowed: Vec<(&str, &[u8])> = owned
143 .iter()
144 .map(|(p, b)| (p.as_str(), b.as_slice()))
145 .collect();
146 let path = root.display().to_string();
147 for mut card in recipes_from_embedded(&borrowed)? {
148 card.source = RecipeSource::Overlay { path: path.clone() };
149 store.upsert_card(card);
150 }
151 }
152 let overlay_toml = root.join("overlay.toml");
153 if overlay_toml.is_file() {
154 let text = std::fs::read_to_string(&overlay_toml).map_err(|e| e.to_string())?;
155 let directives = parse_overlay(&text)?;
156 apply_directives(store, &directives);
157 }
158 Ok(())
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use crate::project::view;
165
166 fn base_store() -> RecipeStore {
167 let alpha: Vec<(&str, &[u8])> = vec![
168 (
169 "book.toml",
170 b"book = \"alpha\"\ntitle = \"Alpha\"\norder = 100\n" as &[u8],
171 ),
172 (
173 "01-basics/add/recipe.toml",
174 b"id = \"add\"\ntitle = \"Add\"\ncodec = \"lisp\"\nsetup = \"s\"\npurpose = \"p\"\norder = 100\n",
175 ),
176 ("01-basics/add/s", b"(+ 1 2)"),
177 ("01-basics/add/p", b"add"),
178 (
179 "01-basics/sub/recipe.toml",
180 b"id = \"sub\"\ntitle = \"Subtract\"\ncodec = \"lisp\"\nsetup = \"s\"\npurpose = \"p\"\norder = 200\n",
181 ),
182 ("01-basics/sub/s", b"(- 3 1)"),
183 ("01-basics/sub/p", b"sub"),
184 ];
185 let mut store = RecipeStore::new();
186 store.register_book(&alpha).unwrap();
187 store
188 }
189
190 #[test]
191 fn parse_overlay_reads_all_directives() {
192 let d = parse_overlay(
193 "[[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",
194 )
195 .unwrap();
196 assert_eq!(d.reorder, [("alpha/01-basics/sub".to_string(), 1)]);
197 assert_eq!(d.hide, ["alpha/01-basics/add"]);
198 assert_eq!(
199 d.retitle,
200 [("alpha/01-basics/sub".to_string(), "Minus".to_string())]
201 );
202 }
203
204 #[test]
205 fn hide_reorder_retitle_apply() {
206 let mut store = base_store();
207 let directives = OverlayDirectives {
208 reorder: vec![("alpha/01-basics/sub".to_string(), 1)],
209 hide: vec!["alpha/01-basics/add".to_string()],
210 retitle: vec![("alpha/01-basics/sub".to_string(), "Minus".to_string())],
211 };
212 apply_directives(&mut store, &directives);
213 assert!(store.card("alpha/01-basics/add").is_none()); let sub = store.card("alpha/01-basics/sub").unwrap();
215 assert_eq!(sub.order, 1); assert_eq!(sub.title, "Minus"); }
218
219 fn temp_dir(tag: &str) -> std::path::PathBuf {
220 let dir =
221 std::env::temp_dir().join(format!("sim-cb-overlay-{}-{}", std::process::id(), tag));
222 let _ = std::fs::remove_dir_all(&dir);
223 dir
224 }
225
226 #[test]
227 fn overlay_adds_to_foreign_book_and_applies_directives() {
228 let root = temp_dir("foreign");
229 std::fs::create_dir_all(root.join("99-extra/mul")).unwrap();
230 std::fs::write(
232 root.join("book.toml"),
233 b"book = \"alpha\"\ntitle = \"Alpha\"\norder = 100\n",
234 )
235 .unwrap();
236 std::fs::write(
237 root.join("99-extra/mul/recipe.toml"),
238 b"id = \"mul\"\ntitle = \"Multiply\"\ncodec = \"lisp\"\nsetup = \"s\"\npurpose = \"p\"\norder = 100\n",
239 )
240 .unwrap();
241 std::fs::write(root.join("99-extra/mul/s"), b"(* 2 3)").unwrap();
242 std::fs::write(root.join("99-extra/mul/p"), b"multiply").unwrap();
243 std::fs::write(
244 root.join("overlay.toml"),
245 b"[[hide]]\nrecipe = \"alpha/01-basics/sub\"\n",
246 )
247 .unwrap();
248
249 let mut store = base_store();
250 load_overlay(&mut store, &root).unwrap();
251
252 let mul = store.card("alpha/99-extra/mul").unwrap();
254 assert_eq!(
255 mul.source,
256 RecipeSource::Overlay {
257 path: root.display().to_string()
258 }
259 );
260 assert!(store.card("alpha/01-basics/sub").is_none());
262 let view = view(&store);
264 assert_eq!(view.books.len(), 1);
265 let chapters: Vec<&str> = view.books[0]
266 .chapters
267 .iter()
268 .map(|c| c.name.as_str())
269 .collect();
270 assert_eq!(chapters, ["01-basics", "99-extra"]);
271 let _ = std::fs::remove_dir_all(&root);
272 }
273
274 #[test]
275 fn overlay_recipe_overrides_crate_recipe_by_id() {
276 let root = temp_dir("override");
277 std::fs::create_dir_all(root.join("01-basics/add")).unwrap();
278 std::fs::write(
279 root.join("book.toml"),
280 b"book = \"alpha\"\ntitle = \"Alpha\"\norder = 100\n",
281 )
282 .unwrap();
283 std::fs::write(
284 root.join("01-basics/add/recipe.toml"),
285 b"id = \"add\"\ntitle = \"Overridden Add\"\ncodec = \"lisp\"\nsetup = \"s\"\npurpose = \"p\"\n",
286 )
287 .unwrap();
288 std::fs::write(root.join("01-basics/add/s"), b"(+ 9 9)").unwrap();
289 std::fs::write(root.join("01-basics/add/p"), b"overridden").unwrap();
290
291 let mut store = base_store();
292 assert_eq!(store.len(), 2);
293 load_overlay(&mut store, &root).unwrap();
294 assert_eq!(store.len(), 2); assert_eq!(
296 store.card("alpha/01-basics/add").unwrap().title,
297 "Overridden Add"
298 );
299 let _ = std::fs::remove_dir_all(&root);
300 }
301
302 #[test]
303 fn missing_overlay_dir_is_noop() {
304 let mut store = base_store();
305 load_overlay(&mut store, Path::new("/nonexistent/sim/cookbook")).unwrap();
306 assert_eq!(store.len(), 2);
307 }
308}