Skip to main content

rosace_asset_codegen/
lib.rs

1//! Build-time asset codegen (A6, layer 2). Scans an app's `assets/` dir and
2//! emits a typed `assets` module of `const Asset` handles, so
3//! `Image::asset(assets::LOGO)` is typo-proof and autocompletes — with the
4//! folder itself as the declaration (no hand-maintained manifest).
5//!
6//! Apps call this from `build.rs`:
7//! ```ignore
8//! fn main() {
9//!     rosace_asset_codegen::generate("assets");
10//! }
11//! ```
12//! and include the result once:
13//! ```ignore
14//! pub mod assets {
15//!     include!(concat!(env!("OUT_DIR"), "/rosace_assets.rs"));
16//! }
17//! ```
18//!
19//! It re-runs only when the asset tree changes (`cargo:rerun-if-changed`), so it
20//! costs nothing on ordinary code edits, and the generated code is a flat tree
21//! of consts — dead simple, so it never slows the compiler down.
22
23use std::collections::HashSet;
24use std::fmt::Write as _;
25use std::fs;
26use std::path::{Path, PathBuf};
27
28/// Scan `assets_dir` and write the typed `assets` module to
29/// `$OUT_DIR/rosace_assets.rs`. Call from `build.rs`. Uses the default handle
30/// type path `rosace::asset::Asset`; see [`generate_with`] to override it.
31pub fn generate(assets_dir: impl AsRef<Path>) {
32    generate_with(assets_dir, "rosace::asset::Asset");
33}
34
35/// Like [`generate`] but lets you name the `Asset` type path (for apps that use
36/// `rosace_core` directly rather than the `rosace` umbrella).
37pub fn generate_with(assets_dir: impl AsRef<Path>, asset_type_path: &str) {
38    let dir = assets_dir.as_ref();
39    println!("cargo:rerun-if-changed={}", dir.display());
40
41    let out = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR set by cargo"))
42        .join("rosace_assets.rs");
43
44    let mut body = String::new();
45    if dir.is_dir() {
46        emit_dir(dir, dir, asset_type_path, 0, &mut body);
47    }
48    // An empty tree still produces a valid (empty) module.
49    fs::write(&out, body).expect("write generated assets module");
50}
51
52/// Emit the handles + nested modules for one directory, recursing depth-first.
53/// `root` is the asset root (for building logical names); `dir` is the current
54/// directory; `depth` drives indentation.
55fn emit_dir(root: &Path, dir: &Path, ty: &str, depth: usize, body: &mut String) {
56    let indent = "    ".repeat(depth);
57
58    // Deterministic order → stable generated output (clean diffs, no churn).
59    let mut entries: Vec<PathBuf> = match fs::read_dir(dir) {
60        Ok(rd) => rd.flatten().map(|e| e.path()).collect(),
61        Err(_) => return,
62    };
63    entries.sort();
64
65    let mut used_consts: HashSet<String> = HashSet::new();
66    let mut used_mods: HashSet<String> = HashSet::new();
67
68    for path in &entries {
69        let file_name = match path.file_name().and_then(|n| n.to_str()) {
70            Some(n) => n,
71            None => continue,
72        };
73        // Skip hidden housekeeping files (.gitkeep, .DS_Store, …).
74        if file_name.starts_with('.') {
75            continue;
76        }
77
78        if path.is_dir() {
79            let mod_name = unique(&mod_ident(file_name), &mut used_mods);
80            let _ = writeln!(body, "{indent}#[allow(non_snake_case)]");
81            let _ = writeln!(body, "{indent}pub mod {mod_name} {{");
82            emit_dir(root, path, ty, depth + 1, body);
83            let _ = writeln!(body, "{indent}}}");
84        } else {
85            // Logical name = path relative to the asset root, forward slashes.
86            let rel = path
87                .strip_prefix(root)
88                .unwrap_or(path)
89                .to_string_lossy()
90                .replace('\\', "/");
91            let const_name = unique(&const_ident(file_name), &mut used_consts);
92            let _ = writeln!(
93                body,
94                "{indent}/// `{rel}`\n{indent}pub const {const_name}: {ty} = {ty}::new(\"{rel}\");"
95            );
96        }
97    }
98}
99
100/// Turn a filename into a SCREAMING_SNAKE const name, dropping the extension
101/// (`home-icon.png` → `HOME_ICON`). Non-alphanumerics become `_`; a leading
102/// digit is prefixed with `_` so it's a valid identifier.
103fn const_ident(file_name: &str) -> String {
104    let stem = file_name.rsplit_once('.').map(|(s, _)| s).unwrap_or(file_name);
105    sanitize(stem, true)
106}
107
108/// Turn a directory name into a valid module identifier (lowercased).
109fn mod_ident(dir_name: &str) -> String {
110    sanitize(dir_name, false)
111}
112
113fn sanitize(s: &str, upper: bool) -> String {
114    let mut out = String::new();
115    for ch in s.chars() {
116        if ch.is_ascii_alphanumeric() {
117            out.push(if upper { ch.to_ascii_uppercase() } else { ch.to_ascii_lowercase() });
118        } else {
119            out.push('_');
120        }
121    }
122    if out.is_empty() {
123        out.push('_');
124    }
125    if out.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) {
126        out.insert(0, '_');
127    }
128    out
129}
130
131/// Ensure a name is unique within its scope by suffixing `_2`, `_3`, … on
132/// collision (e.g. `logo.png` and `logo.svg` both want `LOGO`).
133fn unique(base: &str, used: &mut HashSet<String>) -> String {
134    if used.insert(base.to_string()) {
135        return base.to_string();
136    }
137    let mut n = 2;
138    loop {
139        let candidate = format!("{base}_{n}");
140        if used.insert(candidate.clone()) {
141            return candidate;
142        }
143        n += 1;
144    }
145}