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    let mut out = String::from("&[\n");
50    for (rel, abs) in &files {
51        // `{:?}` emits a valid, escaped Rust string literal for each path.
52        out.push_str(&format!(
53            "    ({:?}, include_bytes!({:?}) as &[u8]),\n",
54            rel,
55            abs.to_string_lossy(),
56        ));
57    }
58    out.push_str("]\n");
59    Ok(out)
60}
61
62fn collect(base: &Path, dir: &Path, out: &mut Vec<(String, PathBuf)>) -> io::Result<()> {
63    let mut entries: Vec<_> = std::fs::read_dir(dir)?.collect::<io::Result<Vec<_>>>()?;
64    entries.sort_by_key(|e| e.file_name());
65    for entry in entries {
66        let path = entry.path();
67        if path.is_dir() {
68            collect(base, &path, out)?;
69        } else if path.is_file() {
70            let rel = path
71                .strip_prefix(base)
72                .map_err(io::Error::other)?
73                .components()
74                .map(|c| c.as_os_str().to_string_lossy())
75                .collect::<Vec<_>>()
76                .join("/");
77            out.push((rel, path));
78        }
79    }
80    Ok(())
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn empty_dir_yields_empty_slice() {
89        let dir = std::env::temp_dir().join(format!("sim-cb-embed-empty-{}", std::process::id()));
90        let code = generate_embed_code(&dir).unwrap();
91        assert_eq!(code, "&[\n]\n");
92    }
93
94    #[test]
95    fn generates_sorted_include_bytes() {
96        let root = std::env::temp_dir().join(format!("sim-cb-embed-{}", std::process::id()));
97        let _ = std::fs::remove_dir_all(&root);
98        std::fs::create_dir_all(root.join("01-basics/add")).unwrap();
99        std::fs::write(root.join("book.toml"), b"book = \"x\"\n").unwrap();
100        std::fs::write(root.join("01-basics/add/recipe.toml"), b"id=\"a\"\n").unwrap();
101        let code = generate_embed_code(&root).unwrap();
102        assert!(code.contains("\"01-basics/add/recipe.toml\""), "{code}");
103        assert!(code.contains("\"book.toml\""), "{code}");
104        assert!(code.contains("include_bytes!("), "{code}");
105        // Entries are sorted by relative path: "01-basics/..." precedes
106        // "book.toml" lexicographically, so the nested recipe comes first.
107        let recipe_at = code.find("recipe.toml").unwrap();
108        let book_at = code.find("book.toml").unwrap();
109        assert!(recipe_at < book_at, "expected sorted order, got:\n{code}");
110        let _ = std::fs::remove_dir_all(&root);
111    }
112}