Skip to main content

sim_cookbook/
embed_codegen.rs

1//! Build-time codegen that embeds a crate's `recipes/` directory.
2//!
3//! A lib crate that ships recipes calls [`write_embed`] from its `build.rs`:
4//!
5//! ```ignore
6//! // build.rs
7//! fn main() {
8//!     sim_cookbook::write_embed("recipes").unwrap();
9//! }
10//! ```
11//!
12//! and includes the generated slice as its [`crate::EmbeddedDir`]:
13//!
14//! ```ignore
15//! pub static RECIPES: sim_cookbook::EmbeddedDir =
16//!     include!(concat!(env!("OUT_DIR"), "/cookbook_recipes.rs"));
17//! ```
18//!
19//! The generated file is a single `&[(path, include_bytes!(abs))]` literal, so
20//! every recipe file is baked into the binary at compile time and rebuilt when
21//! the tree changes. A crate with no `recipes/` directory gets an empty slice.
22
23use std::io;
24use std::path::{Path, PathBuf};
25
26/// Generate the embed slice and write it to `$OUT_DIR/cookbook_recipes.rs`.
27/// `recipes_subdir` is relative to `$CARGO_MANIFEST_DIR`. Intended for use from
28/// a crate's `build.rs`.
29pub fn write_embed(recipes_subdir: &str) -> io::Result<()> {
30    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")
31        .map_err(|_| io::Error::other("CARGO_MANIFEST_DIR not set (call from build.rs)"))?;
32    let out_dir = std::env::var("OUT_DIR")
33        .map_err(|_| io::Error::other("OUT_DIR not set (call from build.rs)"))?;
34    let root = Path::new(&manifest_dir).join(recipes_subdir);
35    println!("cargo:rerun-if-changed={}", root.display());
36    let code = generate_embed_code(&root)?;
37    std::fs::write(Path::new(&out_dir).join("cookbook_recipes.rs"), code)
38}
39
40/// Walk `recipes_root` and return the Rust source for an [`crate::EmbeddedDir`]
41/// literal: a sorted `&[(rel-path, include_bytes!(abs-path))]`. A missing or
42/// empty directory yields an empty slice.
43pub fn generate_embed_code(recipes_root: &Path) -> io::Result<String> {
44    let mut files: Vec<(String, PathBuf)> = Vec::new();
45    if recipes_root.is_dir() {
46        collect(recipes_root, recipes_root, &mut files)?;
47    }
48    files.sort();
49    validate_embedded_tree(recipes_root, &files)?;
50    let mut out = String::from("&[\n");
51    for (rel, abs) in &files {
52        // `{:?}` emits a valid, escaped Rust string literal for each path.
53        out.push_str(&format!(
54            "    ({:?}, include_bytes!({:?}) as &[u8]),\n",
55            rel,
56            abs.to_string_lossy(),
57        ));
58    }
59    out.push_str("]\n");
60    Ok(out)
61}
62
63/// Parse the exact bytes that will be embedded before emitting Rust source.
64///
65/// Deferring this validation until a loadable library is installed turns an
66/// authored manifest defect into a runtime boot failure. Build-time embedding
67/// is the first boundary that has both the complete tree and the canonical
68/// cookbook parser, so it must reject malformed books, chapters, recipes, and
69/// missing referenced files there.
70fn validate_embedded_tree(recipes_root: &Path, files: &[(String, PathBuf)]) -> io::Result<()> {
71    if files.is_empty() {
72        return Ok(());
73    }
74    let owned = files
75        .iter()
76        .map(|(relative, path)| std::fs::read(path).map(|bytes| (relative.as_str(), bytes)))
77        .collect::<io::Result<Vec<_>>>()?;
78    let embedded = owned
79        .iter()
80        .map(|(relative, bytes)| (*relative, bytes.as_slice()))
81        .collect::<Vec<_>>();
82    crate::recipes_from_embedded(&embedded).map_err(|error| {
83        io::Error::new(
84            io::ErrorKind::InvalidData,
85            format!(
86                "invalid cookbook tree at {}: {error}",
87                recipes_root.display()
88            ),
89        )
90    })?;
91    Ok(())
92}
93
94fn collect(base: &Path, dir: &Path, out: &mut Vec<(String, PathBuf)>) -> io::Result<()> {
95    let mut entries: Vec<_> = std::fs::read_dir(dir)?.collect::<io::Result<Vec<_>>>()?;
96    entries.sort_by_key(|e| e.file_name());
97    for entry in entries {
98        let path = entry.path();
99        if path.is_dir() {
100            collect(base, &path, out)?;
101        } else if path.is_file() {
102            let rel = path
103                .strip_prefix(base)
104                .map_err(io::Error::other)?
105                .components()
106                .map(|c| c.as_os_str().to_string_lossy())
107                .collect::<Vec<_>>()
108                .join("/");
109            out.push((rel, path));
110        }
111    }
112    Ok(())
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn empty_dir_yields_empty_slice() {
121        let dir = std::env::temp_dir().join(format!("sim-cb-embed-empty-{}", std::process::id()));
122        let code = generate_embed_code(&dir).unwrap();
123        assert_eq!(code, "&[\n]\n");
124    }
125
126    #[test]
127    fn generates_sorted_include_bytes() {
128        let root = std::env::temp_dir().join(format!("sim-cb-embed-{}", std::process::id()));
129        let _ = std::fs::remove_dir_all(&root);
130        std::fs::create_dir_all(root.join("01-basics/add")).unwrap();
131        std::fs::write(
132            root.join("book.toml"),
133            b"book = \"x\"\ntitle = \"X\"\nchapters = [\"01-basics\"]\n",
134        )
135        .unwrap();
136        std::fs::write(
137            root.join("01-basics/add/recipe.toml"),
138            b"id = \"a\"\ntitle = \"A\"\ncodec = \"lisp\"\nsetup = \"setup.siml\"\npurpose = \"purpose.md\"\n",
139        )
140        .unwrap();
141        std::fs::write(root.join("01-basics/add/setup.siml"), b"(+ 1 2)\n").unwrap();
142        std::fs::write(root.join("01-basics/add/purpose.md"), b"Add values.\n").unwrap();
143        let code = generate_embed_code(&root).unwrap();
144        assert!(code.contains("\"01-basics/add/recipe.toml\""), "{code}");
145        assert!(code.contains("\"book.toml\""), "{code}");
146        assert!(code.contains("include_bytes!("), "{code}");
147        // Entries are sorted by relative path: "01-basics/..." precedes
148        // "book.toml" lexicographically, so the nested recipe comes first.
149        let recipe_at = code.find("recipe.toml").unwrap();
150        let book_at = code.find("book.toml").unwrap();
151        assert!(recipe_at < book_at, "expected sorted order, got:\n{code}");
152        let _ = std::fs::remove_dir_all(&root);
153    }
154
155    #[test]
156    fn rejects_a_tree_that_would_fail_when_the_library_is_loaded() {
157        let root =
158            std::env::temp_dir().join(format!("sim-cb-embed-invalid-{}", std::process::id()));
159        let _ = std::fs::remove_dir_all(&root);
160        std::fs::create_dir_all(&root).unwrap();
161        std::fs::write(root.join("book.toml"), b"title = \"Missing id\"\n").unwrap();
162
163        let error = generate_embed_code(&root).unwrap_err();
164        assert_eq!(error.kind(), io::ErrorKind::InvalidData);
165        assert!(
166            error.to_string().contains("missing required key `book`"),
167            "{error}"
168        );
169        let _ = std::fs::remove_dir_all(&root);
170    }
171}