From ffcaf017d91282d01a942bb3bb57c8548043c74d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20K=C3=B6rner?= Date: Sun, 7 Jun 2026 13:10:22 +0200 Subject: [PATCH] Add dicebear-styles Rust crate Ship the 37 avatar style definitions as embedded JSON data via a data-only Rust crate, mirroring the npm/Composer/PyPI packages. - build.rs scans src/*.json and generates a sorted embed table plus a typed accessor per style, so names are derived from disk, never hand-listed. - Public API: get(name), names(), parsed(name) and per-style fns. - Tests assert exactly 37 styles, every definition is non-empty valid JSON, names are sorted/unique, and accessors match get(). --- .gitignore | 2 + rust/Cargo.toml | 17 ++++++ rust/README.md | 42 +++++++++++++ rust/build.rs | 90 ++++++++++++++++++++++++++++ rust/src/lib.rs | 156 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 307 insertions(+) create mode 100644 rust/Cargo.toml create mode 100644 rust/README.md create mode 100644 rust/build.rs create mode 100644 rust/src/lib.rs diff --git a/.gitignore b/.gitignore index 5e7a183..6762871 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ dist dist-python node_modules +rust/target +rust/Cargo.lock diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..76e519e --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "dicebear-styles" +version = "10.1.0" +edition = "2021" +rust-version = "1.70" +description = "Definition files of the DiceBear avatar styles, shipped as embedded JSON data." +keywords = ["avatar", "dicebear"] +categories = ["multimedia::images"] +homepage = "https://www.dicebear.com" +repository = "https://github.com/dicebear/styles" +# The avatar styles are subject to different licenses; see LICENSE.md. +license-file = "../LICENSE.md" +readme = "README.md" +include = ["src/**/*", "build.rs", "../src/*.json", "README.md"] + +[dependencies] +serde_json = "1" diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 0000000..edbf066 --- /dev/null +++ b/rust/README.md @@ -0,0 +1,42 @@ +# dicebear-styles + +The official [DiceBear](https://www.dicebear.com) avatar style definitions for +Rust, shipped as embedded JSON data. + +An avatar style definition is a JSON document that describes how to create an +avatar — its available elements, colors, and metadata. This crate is **pure +data**: it contains no rendering or avatar-generation logic, mirroring how the +npm (`@dicebear/styles`), Composer (`dicebear/styles`), and PyPI +(`dicebear-styles`) packages distribute the same JSON. The JSON schema for the +definitions can be found in the +[`@dicebear/schema`](https://github.com/dicebear/schema) package. + +## Usage + +```rust +// Look up a style by name (file stem). +let adventurer: &str = dicebear_styles::get("adventurer").unwrap(); + +// Or use the typed accessor. +let lorelei: &str = dicebear_styles::lorelei(); + +// List all 37 available style names (sorted). +for name in dicebear_styles::names() { + println!("{name}"); +} + +// Parse a definition into a `serde_json::Value`. +let value = dicebear_styles::parsed("initials").unwrap(); +``` + +The raw JSON is returned as `&'static str`, embedded at compile time from the +repository's `src/*.json` definition files. The embed table is generated by +`build.rs` in sorted order, so the list of styles always matches the files on +disk. + +## License + +The avatar styles are licensed under different licenses. More information can be +found in the file +[LICENSE.md](https://github.com/dicebear/definitions/blob/main/LICENSE.md) or in +the definition files themselves. diff --git a/rust/build.rs b/rust/build.rs new file mode 100644 index 0000000..ab07918 --- /dev/null +++ b/rust/build.rs @@ -0,0 +1,90 @@ +//! Build script for `dicebear-styles`. +//! +//! Scans the repository's `src/*.json` style definitions and generates a +//! deterministic, sorted lookup table that embeds each file via `include_str!`. +//! This mirrors how the npm / Composer / PyPI packages ship the same pure JSON +//! data, without hand-maintaining the list of style names. + +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +fn main() { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + // The style definitions live one level up, in `/src/*.json`. + let src_dir = manifest_dir.join("..").join("src"); + + // Re-run when the set of definition files changes. + println!("cargo:rerun-if-changed={}", src_dir.display()); + + let mut entries: Vec<(String, String)> = Vec::new(); + for entry in fs::read_dir(&src_dir) + .unwrap_or_else(|e| panic!("failed to read style directory {}: {e}", src_dir.display())) + { + let path = entry.expect("failed to read directory entry").path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let name = path + .file_stem() + .and_then(|s| s.to_str()) + .expect("style file has a non-UTF-8 name") + .to_owned(); + + // Re-run if any individual definition file changes. + println!("cargo:rerun-if-changed={}", path.display()); + + entries.push((name.clone(), format!("../src/{name}.json"))); + } + + // Deterministic, sorted order by style name. + entries.sort_by(|a, b| a.0.cmp(&b.0)); + + assert!( + !entries.is_empty(), + "no style definitions found in {}", + src_dir.display() + ); + + let mut generated = String::new(); + generated.push_str("// @generated by build.rs — do not edit.\n\n"); + + // `STYLES` is a sorted slice of (name, raw JSON) pairs. + generated.push_str( + "/// All embedded style definitions as `(name, raw_json)` pairs, sorted by name.\n", + ); + generated.push_str("pub(crate) static STYLES: &[(&str, &str)] = &[\n"); + for (name, rel_path) in &entries { + // `include_str!` in the generated file resolves relative to OUT_DIR, so + // we anchor each path to `CARGO_MANIFEST_DIR` (resolved at compile time) + // and walk up to the repo `src/` directory. This stays portable across + // machines, unlike an absolute build-host path. + generated.push_str(&format!( + " ({name:?}, include_str!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/{rel_path}\"))),\n" + )); + } + generated.push_str("];\n\n"); + + // `NAMES` is the sorted list of style names, derived from the same entries. + generated.push_str("/// All style names, sorted.\n"); + generated.push_str("pub(crate) static NAMES: &[&str] = &[\n"); + for (name, _) in &entries { + generated.push_str(&format!(" {name:?},\n")); + } + generated.push_str("];\n\n"); + + // Generate a typed accessor function per style, e.g. `big_ears_neutral()`. + for (name, _) in &entries { + let fn_name = name.replace('-', "_"); + generated.push_str(&format!( + "/// Returns the raw JSON definition for the `{name}` style.\n" + )); + generated.push_str(&format!( + "pub fn {fn_name}() -> &'static str {{\n get({name:?}).expect(\"embedded style is always present\")\n}}\n\n" + )); + } + + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + let dest = Path::new(&out_dir).join("styles.rs"); + fs::write(&dest, generated).expect("failed to write generated styles.rs"); +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs new file mode 100644 index 0000000..cbe4b3d --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,156 @@ +//! # dicebear-styles +//! +//! The official [DiceBear](https://www.dicebear.com) avatar style definitions, +//! shipped as embedded JSON data. +//! +//! A style definition is a JSON document describing how to build an avatar — its +//! available elements, colors, and metadata. This crate is **pure data**: it +//! contains no rendering or avatar-generation logic, mirroring how the npm +//! (`@dicebear/styles`), Composer (`dicebear/styles`), and PyPI +//! (`dicebear-styles`) packages distribute the same JSON. The JSON schema for +//! the definitions lives in the [`@dicebear/schema`](https://github.com/dicebear/schema) +//! package. +//! +//! ## Usage +//! +//! ``` +//! // Look up a style by name. +//! let adventurer = dicebear_styles::get("adventurer").unwrap(); +//! assert!(!adventurer.is_empty()); +//! +//! // Or use the typed accessor. +//! let lorelei = dicebear_styles::lorelei(); +//! assert!(!lorelei.is_empty()); +//! +//! // List all available style names. +//! assert_eq!(dicebear_styles::names().len(), 37); +//! +//! // Parse a definition into a `serde_json::Value`. +//! let value = dicebear_styles::parsed("initials").unwrap(); +//! assert!(value.is_object()); +//! ``` + +// The `STYLES` table and the per-style accessor functions are generated by +// `build.rs` from the repository's `src/*.json` definitions, in sorted order. +include!(concat!(env!("OUT_DIR"), "/styles.rs")); + +/// Returns the raw JSON definition for the style with the given `name`, or +/// `None` if no such style exists. +/// +/// Names match the definition file stems, e.g. `"adventurer"` or +/// `"big-ears-neutral"`. +pub fn get(name: &str) -> Option<&'static str> { + // `STYLES` is sorted by name, so a binary search is correct and cheap. + STYLES + .binary_search_by(|(candidate, _)| (*candidate).cmp(name)) + .ok() + .map(|index| STYLES[index].1) +} + +/// Returns the sorted list of all available style names. +pub fn names() -> &'static [&'static str] { + NAMES +} + +/// Returns the style with the given `name` parsed into a [`serde_json::Value`], +/// or `None` if no such style exists. +/// +/// # Panics +/// +/// Panics if an embedded definition is not valid JSON. This cannot happen with +/// the definitions shipped by this crate, which are validated at build time of +/// the underlying packages, but the panic guards against a corrupted build. +pub fn parsed(name: &str) -> Option { + get(name).map(|raw| serde_json::from_str(raw).expect("embedded style definition is valid JSON")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn there_are_exactly_37_styles() { + assert_eq!(names().len(), 37); + assert_eq!(STYLES.len(), 37); + } + + #[test] + fn names_are_sorted_and_unique() { + let names = names(); + let mut sorted = names.to_vec(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(names, sorted.as_slice()); + } + + #[test] + fn every_style_returns_non_empty_valid_json() { + for &name in names() { + let raw = get(name).unwrap_or_else(|| panic!("missing style: {name}")); + assert!(!raw.trim().is_empty(), "empty definition for: {name}"); + + let value: serde_json::Value = serde_json::from_str(raw) + .unwrap_or_else(|e| panic!("invalid JSON for {name}: {e}")); + assert!(value.is_object(), "definition for {name} is not an object"); + } + } + + #[test] + fn parsed_matches_get_for_known_and_unknown() { + assert!(parsed("adventurer").is_some()); + assert!(parsed("does-not-exist").is_none()); + assert!(get("does-not-exist").is_none()); + } + + #[test] + fn typed_accessors_match_lookup() { + assert_eq!(adventurer(), get("adventurer").unwrap()); + assert_eq!(big_ears_neutral(), get("big-ears-neutral").unwrap()); + assert_eq!(pixel_art(), get("pixel-art").unwrap()); + } + + #[test] + fn expected_names_are_present() { + for name in [ + "adventurer", + "adventurer-neutral", + "avataaars", + "avataaars-neutral", + "big-ears", + "big-ears-neutral", + "big-smile", + "bottts", + "bottts-neutral", + "croodles", + "croodles-neutral", + "disco", + "dylan", + "fun-emoji", + "glass", + "glyphs", + "icons", + "identicon", + "initial-face", + "initials", + "lorelei", + "lorelei-neutral", + "micah", + "miniavs", + "notionists", + "notionists-neutral", + "open-peeps", + "personas", + "pixel-art", + "pixel-art-neutral", + "rings", + "shape-grid", + "shapes", + "stripes", + "thumbs", + "toon-head", + "triangles", + ] { + assert!(get(name).is_some(), "expected style missing: {name}"); + } + } +}