Skip to main content

bnto_engine/
recipes.rs

1//! Built-in recipe catalog — engine-owned recipe definitions.
2//!
3//! Each recipe is a `.bnto.json` embedded at compile time via `include_str!()`.
4//! The engine is the source of truth for recipes. TypeScript catalogs
5//! are generated from the engine's catalog snapshot.
6
7/// A built-in recipe with metadata extracted from the definition JSON.
8pub struct BuiltinRecipe {
9    pub slug: String,
10    pub name: String,
11    pub description: String,
12    pub category: String,
13    pub tags: Vec<String>,
14    pub definition_json: &'static str,
15}
16
17/// All built-in recipe definition strings, embedded at compile time.
18const RECIPE_DEFINITIONS: &[&str] = &[
19    include_str!("../recipes/compress-images.bnto.json"),
20    include_str!("../recipes/resize-images.bnto.json"),
21    include_str!("../recipes/convert-image-format.bnto.json"),
22    include_str!("../recipes/rename-files.bnto.json"),
23    include_str!("../recipes/clean-csv.bnto.json"),
24    include_str!("../recipes/rename-csv-columns.bnto.json"),
25    include_str!("../recipes/csv-to-json.bnto.json"),
26    include_str!("../recipes/merge-csv.bnto.json"),
27    include_str!("../recipes/optimize-images-for-web.bnto.json"),
28    include_str!("../recipes/generate-thumbnails.bnto.json"),
29    include_str!("../recipes/compress-and-rename.bnto.json"),
30    include_str!("../recipes/standardize-csv.bnto.json"),
31    include_str!("../recipes/strip-exif.bnto.json"),
32    include_str!("../recipes/watermark-images.bnto.json"),
33    include_str!("../recipes/download-video.bnto.json"),
34    include_str!("../recipes/svg-to-png.bnto.json"),
35    include_str!("../recipes/svg-to-jpeg.bnto.json"),
36    include_str!("../recipes/optimize-svg.bnto.json"),
37    include_str!("../recipes/number-files.bnto.json"),
38    include_str!("../recipes/sanitize-filenames.bnto.json"),
39    include_str!("../recipes/flatten-folders.bnto.json"),
40];
41
42/// Returns all built-in recipes, embedded at compile time.
43pub fn builtin_recipes() -> Vec<BuiltinRecipe> {
44    RECIPE_DEFINITIONS
45        .iter()
46        .map(|json| {
47            let val: serde_json::Value =
48                serde_json::from_str(json).expect("built-in recipe JSON must be valid");
49            BuiltinRecipe {
50                slug: val["id"].as_str().unwrap_or_default().to_string(),
51                name: val["name"].as_str().unwrap_or_default().to_string(),
52                description: val["metadata"]["description"]
53                    .as_str()
54                    .unwrap_or_default()
55                    .to_string(),
56                category: val["metadata"]["category"]
57                    .as_str()
58                    .unwrap_or_default()
59                    .to_string(),
60                tags: val["metadata"]["tags"]
61                    .as_array()
62                    .map(|arr| {
63                        arr.iter()
64                            .filter_map(|v| v.as_str().map(String::from))
65                            .collect()
66                    })
67                    .unwrap_or_default(),
68                definition_json: json,
69            }
70        })
71        .collect()
72}
73
74/// Find a built-in recipe by slug.
75pub fn builtin_recipe_by_slug(slug: &str) -> Option<BuiltinRecipe> {
76    builtin_recipes().into_iter().find(|r| r.slug == slug)
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use bnto_core::PipelineDefinition;
83
84    #[test]
85    fn test_builtin_recipes_count() {
86        assert_eq!(builtin_recipes().len(), 21);
87    }
88
89    #[test]
90    fn test_builtin_recipes_all_have_slugs() {
91        for recipe in builtin_recipes() {
92            assert!(!recipe.slug.is_empty(), "Recipe has empty slug");
93        }
94    }
95
96    #[test]
97    fn test_builtin_recipes_all_have_categories() {
98        for recipe in builtin_recipes() {
99            assert!(
100                !recipe.category.is_empty(),
101                "Recipe '{}' has empty category",
102                recipe.slug,
103            );
104        }
105    }
106
107    #[test]
108    fn test_builtin_recipes_all_parse_as_pipelines() {
109        for recipe in builtin_recipes() {
110            let _def: PipelineDefinition = serde_json::from_str(recipe.definition_json)
111                .unwrap_or_else(|e| panic!("Recipe '{}' failed to parse: {e}", recipe.slug));
112        }
113    }
114
115    #[test]
116    fn test_builtin_recipe_by_slug() {
117        let recipe =
118            builtin_recipe_by_slug("compress-images").expect("compress-images should exist");
119        assert_eq!(recipe.name, "Compress Images");
120        assert_eq!(recipe.category, "image");
121    }
122
123    #[test]
124    fn test_builtin_recipe_by_slug_not_found() {
125        assert!(builtin_recipe_by_slug("nonexistent").is_none());
126    }
127
128    #[test]
129    fn test_builtin_recipes_unique_slugs() {
130        let recipes = builtin_recipes();
131        let mut slugs: Vec<&str> = recipes.iter().map(|r| r.slug.as_str()).collect();
132        slugs.sort();
133        slugs.dedup();
134        assert_eq!(slugs.len(), recipes.len(), "Duplicate slugs found");
135    }
136
137    #[test]
138    fn test_builtin_recipes_all_have_tags() {
139        for recipe in builtin_recipes() {
140            assert!(
141                !recipe.tags.is_empty(),
142                "Recipe '{}' has no tags",
143                recipe.slug,
144            );
145        }
146    }
147
148    #[test]
149    fn test_builtin_recipes_expected_categories() {
150        let recipes = builtin_recipes();
151        let mut categories: Vec<&str> = recipes.iter().map(|r| r.category.as_str()).collect();
152        categories.sort();
153        categories.dedup();
154        assert_eq!(
155            categories,
156            ["file", "image", "spreadsheet", "vector", "video"]
157        );
158    }
159}