Skip to main content

sim_cookbook/
overlay.rs

1//! The user overlay: reorder, hide, retitle, and add recipes without editing
2//! any crate.
3//!
4//! The overlay directory (default `~/.config/sim/cookbook/`) may hold an
5//! `overlay.toml` of directives and, optionally, a `book.toml`-rooted recipe
6//! tree laid out exactly like a crate's `recipes/` dir. Overlay recipes carry
7//! [`RecipeSource::Overlay`]; giving the overlay book the same `book` id as a
8//! crate's book merges the recipes into that book (the view groups by book id).
9//!
10//! Merge precedence (Section 11 of the design): crate recipes load first;
11//! overlay recipes with the same id replace them (`upsert`), new ids are
12//! appended, then directives apply.
13
14use std::path::Path;
15
16use crate::embed::recipes_from_embedded;
17use crate::model::RecipeSource;
18use crate::store::RecipeStore;
19use crate::toml_lite;
20
21/// Parsed `overlay.toml` directives.
22#[derive(Clone, Debug, Default, PartialEq, Eq)]
23pub struct OverlayDirectives {
24    /// `(recipe id, new order)` from `[[reorder]]`.
25    pub reorder: Vec<(String, i64)>,
26    /// recipe ids from `[[hide]]`.
27    pub hide: Vec<String>,
28    /// `(recipe id, new title)` from `[[retitle]]`.
29    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
53/// Parse `overlay.toml` text into directives.
54pub 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
75/// Apply directives to a store: hide removes, reorder sets `order`, retitle sets
76/// `title`. Directives naming an unknown recipe are ignored (the overlay may
77/// reference recipes from libs not currently loaded).
78pub 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
94/// Read a `book.toml`-rooted recipe tree from disk into the embedded form.
95/// Skips `overlay.toml`. Returns an empty list if there is no `book.toml`.
96fn 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
133/// Load the overlay at `root` into `store`: upsert any overlay recipes (so they
134/// override crate recipes by id and merge into matching books), then apply the
135/// `overlay.toml` directives. A missing directory is a no-op.
136pub 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()); // hidden
214        let sub = store.card("alpha/01-basics/sub").unwrap();
215        assert_eq!(sub.order, 1); // reordered
216        assert_eq!(sub.title, "Minus"); // retitled
217    }
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        // Same book id "alpha" -> merges into the existing book.
231        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        // The overlay recipe joined book "alpha".
253        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        // The directive hid sub.
261        assert!(store.card("alpha/01-basics/sub").is_none());
262        // The view shows alpha with two chapters (01-basics add, 99-extra mul).
263        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); // upsert, not append
295        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}