Skip to main content

sim_cookbook/
model.rs

1//! Plain data model for the cookbook.
2//!
3//! A recipe is a tiny lesson shipped by the crate it teaches. These
4//! types carry one recipe's data (a [`RecipeCard`]) and the computed grouping
5//! of all loaded recipes into books and chapters (a [`CookbookView`]). They are
6//! deliberately free of kernel types: recipes flow through the existing
7//! Card/registry data surface, so the kernel gains no cookbook enum.
8
9// conformance: cookbook records carry recipe metadata for generated docs.
10
11/// Where a recipe came from.
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub enum RecipeSource {
14    /// Shipped inside a crate, embedded in its compiled lib.
15    Crate {
16        /// The lib id that owns the recipe.
17        lib: String,
18    },
19    /// Supplied locally by the user overlay directory.
20    Overlay {
21        /// The overlay file path the recipe was loaded from.
22        path: String,
23    },
24}
25
26/// One declared expectation, turning a recipe into a generated test.
27///
28/// After running the recipe's setup, the encoded result of form `form`
29/// (0-based) must equal `result` (encoded in the recipe's codec).
30#[derive(Clone, Debug, PartialEq, Eq)]
31pub struct Expectation {
32    /// Index of the setup form whose result is checked.
33    pub form: usize,
34    /// Expected encoded result, in the recipe's codec.
35    pub result: String,
36}
37
38/// The in-memory record for one recipe. Registered as a Card of kind
39/// `"recipe"`; the cookbook view is a projection over these.
40#[derive(Clone, Debug, PartialEq, Eq)]
41pub struct RecipeCard {
42    /// Globally unique id: `"<book>/<chapter>/<recipe-id>"`.
43    pub id: String,
44    /// Owning lib id (the book).
45    pub book: String,
46    /// Chapter directory name (or overlay chapter).
47    pub chapter: String,
48    /// Resolved human chapter title.
49    pub chapter_title: String,
50    /// One-line chapter summary (may be empty).
51    pub chapter_summary: String,
52    /// Human recipe title.
53    pub title: String,
54    /// Registered codec name for decoding `setup`.
55    pub codec: String,
56    /// Raw setup bytes, decoded on demand through `codec`.
57    pub setup: Vec<u8>,
58    /// Purpose document contents (Markdown, ASCII).
59    pub purpose: String,
60    /// Sort key within the chapter; lower runs first.
61    pub order: i64,
62    /// Sort key of this recipe's chapter among chapters.
63    pub chapter_order: i64,
64    /// Sort key of this recipe's book among books.
65    pub book_order: i64,
66    /// Human book title.
67    pub book_title: String,
68    /// One-line book summary (may be empty).
69    pub book_summary: String,
70    /// Free tags for search and filtering.
71    pub tags: Vec<String>,
72    /// Lib ids that must be loaded for this recipe to run.
73    pub requires: Vec<String>,
74    /// Declared expectations (empty when the recipe is not a test).
75    pub expect: Vec<Expectation>,
76    /// Provenance of this recipe.
77    pub source: RecipeSource,
78}
79
80/// The full computed cookbook: every loaded recipe grouped into books.
81#[derive(Clone, Debug, PartialEq, Eq, Default)]
82pub struct CookbookView {
83    /// Books sorted by `book_order`, then book id.
84    pub books: Vec<BookView>,
85}
86
87/// One loadable lib entry in the top-level cookbook tree.
88#[derive(Clone, Debug, PartialEq, Eq)]
89pub struct LibView {
90    /// Cookbook-facing lib id, such as `numbers/i64`.
91    pub id: String,
92    /// Human lib title.
93    pub title: String,
94    /// Whether the lib is loaded in the projected recipe store.
95    pub loaded: bool,
96    /// Grouped recipe chapters for loaded libs.
97    pub groups: Vec<ChapterView>,
98    /// Lifecycle load recipes for unloaded libs.
99    pub recipes: Vec<RecipeCard>,
100}
101
102/// One book (all recipes from one crate) in a [`CookbookView`].
103#[derive(Clone, Debug, PartialEq, Eq)]
104pub struct BookView {
105    /// Lib id of the book.
106    pub id: String,
107    /// Human book title.
108    pub title: String,
109    /// One-line book summary (may be empty).
110    pub summary: String,
111    /// Chapters sorted by `chapter_order`, then name.
112    pub chapters: Vec<ChapterView>,
113}
114
115/// The full catalog grouped into a two-level tree: family -> domain (book) ->
116/// chapter -> recipe (COOK8.00 COVERAGE axis).
117///
118/// The grouping needs NO per-recipe metadata: the level-1 family is derived from
119/// each book's id prefix (`numbers/cas` -> `numbers`, `organ/binding` ->
120/// `organ`, `audio-dsp` -> `audio`), and the level-2 domain is the book itself.
121/// So the whole constellation browses by subsystem without a hand-maintained
122/// group list.
123#[derive(Clone, Debug, PartialEq, Eq, Default)]
124pub struct GroupedView {
125    /// Families in the order their lowest-ordered book appears in
126    /// [`CookbookView`] (book order is total, so this order is deterministic).
127    pub families: Vec<FamilyView>,
128}
129
130/// One level-1 family (subsystem) in a [`GroupedView`], holding its domain books.
131#[derive(Clone, Debug, PartialEq, Eq)]
132pub struct FamilyView {
133    /// Family id derived from the book prefix (`numbers`, `organ`, `codec`, ...).
134    pub family: String,
135    /// The domain books in this family, in the same order as [`CookbookView`].
136    pub books: Vec<BookView>,
137}
138
139/// One chapter within a [`BookView`].
140#[derive(Clone, Debug, PartialEq, Eq)]
141pub struct ChapterView {
142    /// Chapter directory name (stable id within the book).
143    pub name: String,
144    /// Human chapter title.
145    pub title: String,
146    /// One-line chapter summary (may be empty).
147    pub summary: String,
148    /// Recipes sorted by `order`, then id.
149    pub recipes: Vec<RecipeCard>,
150}
151
152/// The outcome of running a recipe's setup through its codec.
153#[derive(Clone, Debug, PartialEq, Eq)]
154pub struct RecipeRun {
155    /// The recipe id that was run.
156    pub recipe: String,
157    /// Number of setup forms evaluated.
158    pub forms: usize,
159    /// Encoded result of each form, in the recipe's codec.
160    pub results: Vec<String>,
161    /// Expectation check results (empty when no expectations).
162    pub checks: Vec<CheckResult>,
163    /// True when every form evaluated and every check passed.
164    pub ok: bool,
165}
166
167/// The result of checking one [`Expectation`] after a run.
168#[derive(Clone, Debug, PartialEq, Eq)]
169pub struct CheckResult {
170    /// Index of the checked setup form.
171    pub form: usize,
172    /// Expected encoded result.
173    pub expected: String,
174    /// Actual encoded result.
175    pub actual: String,
176    /// True when `expected == actual`.
177    pub pass: bool,
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    fn sample_card() -> RecipeCard {
185        RecipeCard {
186            id: "numbers-f64/01-basics/add-two-numbers".to_string(),
187            book: "numbers-f64".to_string(),
188            chapter: "01-basics".to_string(),
189            chapter_title: "Basics".to_string(),
190            chapter_summary: String::new(),
191            title: "Add two numbers".to_string(),
192            codec: "lisp".to_string(),
193            setup: b"(+ 1 2)".to_vec(),
194            purpose: "Add two f64 values.".to_string(),
195            order: 100,
196            chapter_order: 100,
197            book_order: 200,
198            book_title: "Numbers (f64)".to_string(),
199            book_summary: String::new(),
200            tags: vec!["arithmetic".to_string(), "intro".to_string()],
201            requires: vec!["numbers-f64".to_string()],
202            expect: vec![Expectation {
203                form: 0,
204                result: "3".to_string(),
205            }],
206            source: RecipeSource::Crate {
207                lib: "numbers-f64".to_string(),
208            },
209        }
210    }
211
212    #[test]
213    fn recipe_card_round_trips_its_fields() {
214        let card = sample_card();
215        let clone = card.clone();
216        assert_eq!(card, clone);
217        assert_eq!(card.id, "numbers-f64/01-basics/add-two-numbers");
218        assert_eq!(card.setup, b"(+ 1 2)");
219        assert_eq!(card.expect[0].form, 0);
220        assert_eq!(card.expect[0].result, "3");
221        assert_eq!(
222            card.source,
223            RecipeSource::Crate {
224                lib: "numbers-f64".to_string()
225            }
226        );
227    }
228
229    #[test]
230    fn cookbook_view_nests_book_chapter_recipe() {
231        let card = sample_card();
232        let view = CookbookView {
233            books: vec![BookView {
234                id: card.book.clone(),
235                title: card.book_title.clone(),
236                summary: String::new(),
237                chapters: vec![ChapterView {
238                    name: card.chapter.clone(),
239                    title: card.chapter_title.clone(),
240                    summary: String::new(),
241                    recipes: vec![card.clone()],
242                }],
243            }],
244        };
245        assert_eq!(view.books[0].chapters[0].recipes[0], card);
246        assert_eq!(view.books[0].id, "numbers-f64");
247    }
248
249    #[test]
250    fn lib_view_carries_loaded_state_and_either_groups_or_recipes() {
251        let card = sample_card();
252        let loaded = LibView {
253            id: card.book.clone(),
254            title: card.book_title.clone(),
255            loaded: true,
256            groups: vec![ChapterView {
257                name: card.chapter.clone(),
258                title: card.chapter_title.clone(),
259                summary: String::new(),
260                recipes: vec![card.clone()],
261            }],
262            recipes: Vec::new(),
263        };
264        let unloaded = LibView {
265            id: "numbers-i64".to_string(),
266            title: "Numbers (i64)".to_string(),
267            loaded: false,
268            groups: Vec::new(),
269            recipes: vec![card.clone()],
270        };
271
272        assert!(loaded.loaded);
273        assert!(loaded.recipes.is_empty());
274        assert_eq!(loaded.groups[0].recipes[0], card);
275        assert!(!unloaded.loaded);
276        assert!(unloaded.groups.is_empty());
277        assert_eq!(unloaded.recipes.len(), 1);
278    }
279
280    #[test]
281    fn recipe_run_reports_ok() {
282        let run = RecipeRun {
283            recipe: "numbers-f64/01-basics/add-two-numbers".to_string(),
284            forms: 1,
285            results: vec!["3".to_string()],
286            checks: vec![CheckResult {
287                form: 0,
288                expected: "3".to_string(),
289                actual: "3".to_string(),
290                pass: true,
291            }],
292            ok: true,
293        };
294        assert!(run.ok);
295        assert!(run.checks[0].pass);
296    }
297}