use std::collections::HashSet;
use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow, bail};
use schemars::JsonSchema;
use serde::Deserialize;
#[path = "app/feature_graphs.rs"]
pub mod feature_graphs;
fn format_dep_line(dep: &feature_graphs::Dep) -> String {
use feature_graphs::DepSource;
let mut parts: Vec<String> = Vec::new();
match &dep.source {
DepSource::Path(p) => parts.push(format!("path = {p:?}")),
DepSource::Version(v) => parts.push(format!("version = {v:?}")),
DepSource::PackageRename { package, version } => {
parts.push(format!("package = {package:?}"));
parts.push(format!("version = {version:?}"));
}
}
if !dep.default_features {
parts.push("default-features = false".to_string());
}
if !dep.features.is_empty() {
parts.push(format!("features = {:?}", dep.features));
}
if dep.optional {
parts.push("optional = true".to_string());
}
let bare_version_ok = matches!(&dep.source, DepSource::Version(_))
&& dep.default_features
&& dep.features.is_empty()
&& !dep.optional;
if bare_version_ok {
if let DepSource::Version(v) = &dep.source {
return format!("{} = {:?}\n", dep.name, v);
}
}
format!("{} = {{ {} }}\n", dep.name, parts.join(", "))
}
pub const SCHEMA_TAG: &str = "rlvgl-app/v0";
const PRONGS: &[&str] = &["linux", "bare_metal", "freertos", "zephyr"];
const GENERATORS: &[&str] = &["creator-bsp-pac", "hosted", "hand_written"];
const HAND_WRITTEN_BOARDS: &[&str] = &["stm32h747i_disco", "beaglebone_black_nhd_cape"];
const ASSET_CLASSES: &[&str] = &[
"image_rgb565",
"image_rle_a8",
"palette",
"font",
"audio_pcm",
"audio_lufs_capture",
"icon",
];
const LAYOUT_FORMATS: &[&str] = &["figma_export_v1", "uml_widget_v1", "rust_inline_v1"];
const THEME_FORMATS: &[&str] = &["chakra_tokens_v1", "raw_palette_v1"];
const SM_GENERATORS: &[&str] = &["mcp-statechart"];
const I18N_FORMATS: &[&str] = &["rlvgl_i18n_v1"];
fn is_ref_id(s: &str) -> bool {
if s.is_empty() || s.len() > 63 {
return false;
}
let mut chars = s.chars();
let first = chars.next().unwrap();
if !first.is_ascii_lowercase() {
return false;
}
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
pub schema: String,
pub name: String,
pub target: Target,
#[serde(default)]
pub controller: Option<Controller>,
#[serde(default)]
pub state_machine: Option<StateMachine>,
#[serde(default)]
pub assets: Vec<Asset>,
#[serde(default)]
pub screens: Vec<Screen>,
#[serde(default)]
pub theme: Option<Theme>,
#[serde(default)]
pub i18n: Option<I18n>,
#[serde(default)]
#[schemars(with = "Option<serde_json::Value>")]
pub metadata: Option<serde_yaml::Mapping>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Target {
pub vendor: String,
pub board: String,
pub prong: String,
#[serde(default)]
pub chip: Option<String>,
#[serde(default)]
pub features: Vec<String>,
#[serde(default)]
pub generator: Option<String>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Controller {
#[serde(rename = "crate")]
pub crate_name: String,
#[serde(default)]
pub path: Option<PathBuf>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub capabilities: Option<String>,
#[serde(default)]
pub features: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct StateMachine {
pub source: PathBuf,
pub generator: String,
pub vendored_crate: PathBuf,
#[serde(default = "default_true")]
pub verification_vectors: bool,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Asset {
pub id: String,
pub class: String,
pub source: PathBuf,
#[serde(default)]
pub palette_ref: Option<String>,
#[serde(default)]
#[schemars(with = "Option<serde_json::Value>")]
pub options: Option<serde_yaml::Mapping>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Screen {
pub id: String,
#[serde(default)]
pub state: Option<String>,
pub layout: PathBuf,
pub layout_format: String,
#[serde(default)]
pub default: bool,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Theme {
pub source: PathBuf,
pub format: String,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct I18n {
pub bundle_dir: PathBuf,
pub default_locale: String,
#[serde(default)]
pub locales: Vec<String>,
pub format: String,
}
pub fn find_workspace_root(start: &Path) -> PathBuf {
let mut cur: Option<&Path> = Some(start);
while let Some(dir) = cur {
let cargo = dir.join("Cargo.toml");
if cargo.is_file() {
if let Ok(text) = std::fs::read_to_string(&cargo) {
if text
.lines()
.any(|l| l.trim_start().starts_with("[workspace]"))
{
return dir.to_path_buf();
}
}
}
cur = dir.parent();
}
start.to_path_buf()
}
fn resolve_manifest_path(manifest_dir: &Path, ws_root: &Path, rel: &Path) -> Result<PathBuf> {
if rel.is_absolute() {
bail!(
"rule 4 (path safety): absolute paths are rejected: {}",
rel.display()
);
}
let joined = manifest_dir.join(rel);
let normalised = lexical_normalise(&joined);
let ws_canon = lexical_normalise(ws_root);
if !normalised.starts_with(&ws_canon) {
bail!(
"rule 4 (path safety): path resolves outside workspace root {}: {}",
ws_root.display(),
rel.display()
);
}
Ok(normalised)
}
fn lexical_normalise(p: &Path) -> PathBuf {
let mut out: Vec<std::ffi::OsString> = Vec::new();
for c in p.components() {
match c {
std::path::Component::ParentDir => {
out.pop();
}
std::path::Component::CurDir => {}
std::path::Component::Normal(s) => out.push(s.to_os_string()),
std::path::Component::RootDir => {
out.clear();
out.push("/".into());
}
std::path::Component::Prefix(p) => out.push(p.as_os_str().to_os_string()),
}
}
let mut buf = PathBuf::new();
for o in out {
buf.push(o);
}
buf
}
fn chipdb_find(vendor: &str, board: &str) -> Option<String> {
match vendor {
"esp" => rlvgl_chips_esp::find(board).map(|b| b.chip.to_string()),
"stm" => rlvgl_chips_stm::find(board).map(|b| b.chip.to_string()),
"ti" => rlvgl_chips_ti::find(board).map(|b| b.chip.to_string()),
"nrf" => rlvgl_chips_nrf::find(board).map(|b| b.chip.to_string()),
"nxp" => rlvgl_chips_nxp::find(board).map(|b| b.chip.to_string()),
"renesas" => rlvgl_chips_renesas::find(board).map(|b| b.chip.to_string()),
"silabs" => rlvgl_chips_silabs::find(board).map(|b| b.chip.to_string()),
"rp2040" => rlvgl_chips_rp2040::find(board).map(|b| b.chip.to_string()),
"microchip" => rlvgl_chips_microchip::find(board).map(|b| b.chip.to_string()),
_ => None,
}
}
const VENDORS: &[&str] = &[
"esp",
"stm",
"ti",
"nrf",
"nxp",
"renesas",
"silabs",
"rp2040",
"microchip",
];
pub fn app_schema_json() -> Result<String> {
let schema = schemars::schema_for!(Manifest);
Ok(serde_json::to_string_pretty(&schema)?)
}
pub fn new_scaffold(dir: &Path, name: &str) -> Result<PathBuf> {
if !is_ref_id(name) {
bail!(
"app name '{name}' is not a valid kebab-case ref-id (chapter 01 §3) — must match ^[a-z][a-z0-9-]*$, max 63 chars"
);
}
let target = dir.join(name);
if target.exists() {
bail!(
"refusing to scaffold into existing path: {} (move or delete it first)",
target.display()
);
}
std::fs::create_dir_all(target.join("layouts"))
.map_err(|e| anyhow!("create_dir_all {}: {e}", target.display()))?;
let manifest_body = format!(
r#"# {name}/app.yaml — rlvgl-app/v0 starter manifest.
#
# Validate: rlvgl-creator app from-yaml --validate-only {name}/app.yaml
# Inspect: rlvgl-creator app inspect {name}/app.yaml
# Emit: rlvgl-creator app from-yaml {name}/app.yaml --out target/{name}-emit
#
# Adjust target.vendor / target.board to match your hardware.
# Run `rlvgl-creator app schema` for the JSON Schema describing
# every field — see docs/app-schema/01-manifest-schema.md for the
# normative grammar.
schema: rlvgl-app/v0
name: {name}
target:
vendor: esp
board: beetle_esp32c3
prong: bare_metal
generator: hosted
screens:
- id: main-screen
layout: layouts/main_screen.rs
layout_format: rust_inline_v1
default: true
"#
);
let manifest_path = target.join("app.yaml");
std::fs::write(&manifest_path, &manifest_body)
.map_err(|e| anyhow!("write {}: {e}", manifest_path.display()))?;
let layout_body = "// layouts/main_screen.rs — starter layout.\n\
//\n\
// The orchestrator copies this file verbatim into\n\
// src/screens/main_screen.rs at emit time per chapter 02 §7.7\n\
// (rust_inline_v1). Replace this placeholder with real widget\n\
// construction once the surrounding scaffold is fleshed out.\n\
\n\
pub fn build() {}\n";
std::fs::write(target.join("layouts/main_screen.rs"), layout_body)
.map_err(|e| anyhow!("write layouts/main_screen.rs: {e}"))?;
validate(&manifest_path).map_err(|e| {
anyhow!(
"scaffolded manifest at {} failed validation: {e}",
manifest_path.display()
)
})?;
Ok(manifest_path)
}
pub fn inspect(manifest_path: &Path) -> Result<()> {
let m = validate(manifest_path)?;
println!("Manifest: {}", manifest_path.display());
println!(" schema: {}", m.schema);
println!(" name: {}", m.name);
println!();
println!("Target:");
println!(" vendor: {}", m.target.vendor);
println!(" board: {}", m.target.board);
println!(" prong: {}", m.target.prong);
if let Some(chip) = &m.target.chip {
println!(" chip: {chip}");
}
println!(
" generator: {}",
m.target.generator.as_deref().unwrap_or("creator-bsp-pac")
);
if !m.target.features.is_empty() {
println!(" features: {}", m.target.features.join(", "));
}
if let Some(c) = &m.controller {
println!();
println!("Controller:");
println!(" crate: {}", c.crate_name);
if let Some(p) = &c.path {
println!(" path: {}", p.display());
}
if let Some(v) = &c.version {
println!(" version: {v}");
}
if let Some(caps) = &c.capabilities {
println!(" capabilities: {caps}");
}
if !c.features.is_empty() {
println!(" features: {}", c.features.join(", "));
}
}
if let Some(sm) = &m.state_machine {
println!();
println!("State machine:");
println!(" source: {}", sm.source.display());
println!(" generator: {}", sm.generator);
println!(" vendored_crate: {}", sm.vendored_crate.display());
println!(" verification_vectors: {}", sm.verification_vectors);
}
if !m.assets.is_empty() {
println!();
println!("Assets ({}):", m.assets.len());
let mut classes: std::collections::BTreeMap<&str, usize> =
std::collections::BTreeMap::new();
for a in &m.assets {
*classes.entry(a.class.as_str()).or_insert(0) += 1;
}
for (class, n) in &classes {
println!(" {class:<24} x{n}");
}
}
if !m.screens.is_empty() {
println!();
println!("Screens ({}):", m.screens.len());
for s in &m.screens {
let mark = if s.default { " [default]" } else { "" };
let st = s
.state
.as_deref()
.map(|x| format!(" state={x}"))
.unwrap_or_default();
println!(" {} ({}){mark}{st}", s.id, s.layout_format);
}
}
if let Some(t) = &m.theme {
println!();
println!("Theme:");
println!(" source: {}", t.source.display());
println!(" format: {}", t.format);
}
if let Some(i) = &m.i18n {
println!();
println!("i18n:");
println!(" bundle_dir: {}", i.bundle_dir.display());
println!(" default_locale: {}", i.default_locale);
println!(" format: {}", i.format);
if !i.locales.is_empty() {
println!(" locales: {}", i.locales.join(", "));
}
}
let generator = m.target.generator.as_deref().unwrap_or("creator-bsp-pac");
let mut stages: Vec<&str> = Vec::new();
if generator == "creator-bsp-pac" {
stages.push("bsp-gen");
}
if !m.assets.is_empty() {
stages.push("asset-pipeline");
}
if m.state_machine.is_some() {
stages.push("sm-gen");
}
if m.i18n.is_some() {
stages.push("i18n");
}
if m.theme.is_some() {
stages.push("theme");
}
println!();
if stages.is_empty() {
println!("Eligible stage-3 sub-generators: (none)");
} else {
println!(
"Eligible stage-3 sub-generators ({}): {}",
stages.len(),
stages.join(", ")
);
}
Ok(())
}
pub fn validate(manifest_path: &Path) -> Result<Manifest> {
let text = std::fs::read_to_string(manifest_path)
.map_err(|e| anyhow!("read {}: {e}", manifest_path.display()))?;
let manifest: Manifest =
serde_yaml::from_str(&text).map_err(|e| anyhow!("rule 7 (parse / unknown keys): {e}"))?;
let manifest_dir = manifest_path
.parent()
.ok_or_else(|| anyhow!("manifest has no parent dir: {}", manifest_path.display()))?;
let ws_root = find_workspace_root(manifest_dir);
if manifest.schema != SCHEMA_TAG {
bail!(
"rule 1 (schema tag): expected {}, got {}",
SCHEMA_TAG,
manifest.schema
);
}
if !is_ref_id(&manifest.name) {
bail!(
"rule 3 (reference id format): name '{}' must match ^[a-z][a-z0-9-]*$ and be <= 63 chars",
manifest.name
);
}
let mut asset_ids: HashSet<&str> = HashSet::new();
for a in &manifest.assets {
if !is_ref_id(&a.id) {
bail!(
"rule 3 (reference id format): assets[].id '{}' must match ^[a-z][a-z0-9-]*$",
a.id
);
}
if !asset_ids.insert(a.id.as_str()) {
bail!(
"rule 3 (reference id format): duplicate asset id '{}'",
a.id
);
}
}
let mut screen_ids: HashSet<&str> = HashSet::new();
for s in &manifest.screens {
if !is_ref_id(&s.id) {
bail!(
"rule 3 (reference id format): screens[].id '{}' must match ^[a-z][a-z0-9-]*$",
s.id
);
}
if !screen_ids.insert(s.id.as_str()) {
bail!(
"rule 3 (reference id format): duplicate screen id '{}'",
s.id
);
}
}
if let Some(c) = &manifest.controller {
if let Some(p) = &c.path {
resolve_manifest_path(manifest_dir, &ws_root, p)?;
}
}
if let Some(sm) = &manifest.state_machine {
resolve_manifest_path(manifest_dir, &ws_root, &sm.source)?;
resolve_manifest_path(manifest_dir, &ws_root, &sm.vendored_crate)?;
}
for a in &manifest.assets {
resolve_manifest_path(manifest_dir, &ws_root, &a.source)?;
}
for s in &manifest.screens {
resolve_manifest_path(manifest_dir, &ws_root, &s.layout)?;
}
if let Some(t) = &manifest.theme {
resolve_manifest_path(manifest_dir, &ws_root, &t.source)?;
}
if let Some(i) = &manifest.i18n {
resolve_manifest_path(manifest_dir, &ws_root, &i.bundle_dir)?;
}
if !VENDORS.contains(&manifest.target.vendor.as_str()) {
bail!(
"rule 5 (cross-references): unknown chipdb vendor '{}'; valid: {:?}",
manifest.target.vendor,
VENDORS
);
}
let resolved_chip = chipdb_find(&manifest.target.vendor, &manifest.target.board)
.ok_or_else(|| {
anyhow!(
"rule 5 (cross-references): board '{}' not registered with rlvgl-chips-{} (find() returned None)",
manifest.target.board,
manifest.target.vendor
)
})?;
if resolved_chip.is_empty() {
bail!(
"rule 5 (cross-references): board '{}' has empty chip field in chipdb",
manifest.target.board
);
}
if let Some(declared) = &manifest.target.chip {
if declared != &resolved_chip {
bail!(
"rule 5 (cross-references): target.chip '{}' does not match chipdb's declared chip '{}' for board '{}'",
declared,
resolved_chip,
manifest.target.board
);
}
}
let generator = manifest
.target
.generator
.as_deref()
.unwrap_or("creator-bsp-pac");
if !GENERATORS.contains(&generator) {
bail!(
"rule 5 (cross-references): unknown target.generator '{}'; valid: {:?}",
generator,
GENERATORS
);
}
if generator == "hand_written" && !HAND_WRITTEN_BOARDS.contains(&manifest.target.board.as_str())
{
bail!(
"rule 5 (cross-references): target.generator: hand_written requires the board to be on the §5.6 allow-list; '{}' is not. Allow-list: {:?}",
manifest.target.board,
HAND_WRITTEN_BOARDS
);
}
if !PRONGS.contains(&manifest.target.prong.as_str()) {
bail!(
"rule 5 (cross-references): unknown target.prong '{}'; valid: {:?}",
manifest.target.prong,
PRONGS
);
}
for a in &manifest.assets {
if !ASSET_CLASSES.contains(&a.class.as_str()) {
bail!(
"rule 5 (cross-references): asset '{}' has unknown class '{}'; valid: {:?}",
a.id,
a.class,
ASSET_CLASSES
);
}
if let Some(pref) = &a.palette_ref {
let target = manifest
.assets
.iter()
.find(|other| other.id == *pref)
.ok_or_else(|| {
anyhow!(
"rule 5 (cross-references): asset '{}' palette_ref '{}' not found in assets[]",
a.id,
pref
)
})?;
if target.class != "palette" {
bail!(
"rule 5 (cross-references): asset '{}' palette_ref '{}' has class '{}', not 'palette'",
a.id,
pref,
target.class
);
}
if pref == &a.id {
bail!(
"rule 5 (cross-references): asset '{}' palette_ref points at itself (cycle)",
a.id
);
}
}
}
for s in &manifest.screens {
if !LAYOUT_FORMATS.contains(&s.layout_format.as_str()) {
bail!(
"rule 5 (cross-references): screen '{}' has unknown layout_format '{}'; valid: {:?}",
s.id,
s.layout_format,
LAYOUT_FORMATS
);
}
}
if let Some(t) = &manifest.theme {
if !THEME_FORMATS.contains(&t.format.as_str()) {
bail!(
"rule 5 (cross-references): theme.format '{}' not in supported set: {:?}",
t.format,
THEME_FORMATS
);
}
}
if let Some(sm) = &manifest.state_machine {
if !SM_GENERATORS.contains(&sm.generator.as_str()) {
bail!(
"rule 5 (cross-references): state_machine.generator '{}' not in supported set: {:?}",
sm.generator,
SM_GENERATORS
);
}
let ext = sm
.source
.extension()
.and_then(|s| s.to_str())
.map(|s| s.to_ascii_lowercase());
match ext.as_deref() {
Some("scxml") | Some("uml") => {}
_ => bail!(
"rule 5 (cross-references): state_machine.source must end in .scxml or .uml: {}",
sm.source.display()
),
}
let vendored = resolve_manifest_path(manifest_dir, &ws_root, &sm.vendored_crate)?;
if !vendored.is_dir() {
bail!(
"rule 5 (cross-references): state_machine.vendored_crate must resolve to a directory: {}",
vendored.display()
);
}
let self_manifest = vendored.join(".mcp-statechart-manifest.json");
if !self_manifest.is_file() {
bail!(
"rule 5 (cross-references): state_machine.vendored_crate is missing the SM-gen self-manifest at {} (chapter 04 §5.5)",
self_manifest.display()
);
}
}
if let Some(i) = &manifest.i18n {
if !I18N_FORMATS.contains(&i.format.as_str()) {
bail!(
"rule 5 (cross-references): i18n.format '{}' not in supported set: {:?}",
i.format,
I18N_FORMATS
);
}
}
if let Some(c) = &manifest.controller {
if c.crate_name.is_empty() {
bail!("rule 5 (cross-references): controller.crate must be non-empty");
}
if c.path.is_some() && c.version.is_some() {
bail!(
"rule 5 (cross-references): controller.path and controller.version are mutually exclusive"
);
}
}
if manifest.state_machine.is_none() {
let default_count = manifest.screens.iter().filter(|s| s.default).count();
if default_count != 1 {
bail!(
"rule 6 (state-machine invariant): when state_machine: is absent, exactly one screen must have default: true (found {})",
default_count
);
}
for s in &manifest.screens {
if let Some(state) = &s.state {
bail!(
"rule 6 (state-machine invariant): screen '{}' sets state: '{}' but state_machine: is absent — there is no SM to resolve the state name against (chapter 04 §6 CV-2)",
s.id,
state
);
}
}
}
Ok(manifest)
}
pub fn run_from_yaml(
manifest: &Path,
out: Option<&Path>,
validate_only: bool,
check: bool,
force: bool,
jobs: usize,
bsp_gen: BspGenFn,
) -> Result<()> {
let m = validate(manifest)?;
eprintln!(
"ok: {} (schema={}, target={}/{}/{}, screens={}, assets={})",
manifest.display(),
m.schema,
m.target.vendor,
m.target.board,
m.target.prong,
m.screens.len(),
m.assets.len(),
);
if validate_only {
return Ok(());
}
let out = out.ok_or_else(|| {
anyhow!(
"--out <DIR> is required when emitting (omit it and pass --validate-only \
to run the chapter 01 §6 validator only)"
)
})?;
let manifest_dir = manifest
.parent()
.ok_or_else(|| anyhow!("manifest has no parent: {}", manifest.display()))?;
let ws_root = find_workspace_root(manifest_dir);
if check {
let staged = StagingDir::new().map_err(|e| anyhow!("create temp dir for --check: {e}"))?;
let mut orch = Orchestrator::new(
m,
manifest_dir.to_path_buf(),
ws_root,
staged.path().to_path_buf(),
)
.with_bsp_gen(bsp_gen)
.with_jobs(jobs);
let new_inv = orch.run()?;
let _ = run_rustfmt_on_emitted(&new_inv, staged.path());
let diffs = compare_emission(staged.path(), out, &new_inv)?;
if diffs.is_empty() {
eprintln!(
"check: clean ({} files, no divergence from {})",
new_inv.entries.len(),
out.display()
);
return Ok(());
}
eprintln!(
"check: {} divergence(s) between staged emission and {}:",
diffs.len(),
out.display()
);
for d in &diffs {
eprintln!(" {}: {}", d.kind, d.path);
}
bail!(
"--check found {} divergence(s); regenerate with `app from-yaml --out {} {}`",
diffs.len(),
out.display(),
manifest.display()
);
}
if out.exists() {
let untracked = scan_untracked_against_inventory(out)?;
if !untracked.is_empty() && !force {
let mut msg = format!(
"{} contains {} file(s) not recorded in a previous inventory:\n",
out.display(),
untracked.len()
);
for u in untracked.iter().take(10) {
msg.push_str(&format!(" - {u}\n"));
}
if untracked.len() > 10 {
msg.push_str(&format!(" ... ({} more)\n", untracked.len() - 10));
}
msg.push_str("Pass --force to overwrite, or move these files outside <out> first.");
bail!(msg);
}
}
let prev_inventory = read_inventory(out).ok();
let mut orch = Orchestrator::new(m, manifest_dir.to_path_buf(), ws_root, out.to_path_buf())
.with_bsp_gen(bsp_gen)
.with_jobs(jobs);
let inv = orch.run()?;
let mut deleted = 0_usize;
if let Some(prev) = prev_inventory {
let new_paths: std::collections::HashSet<&str> =
inv.entries.iter().map(|e| e.path.as_str()).collect();
for old_entry in &prev.entries {
if !new_paths.contains(old_entry.path.as_str()) {
let p = out.join(&old_entry.path);
if p.exists() {
let _ = std::fs::remove_file(&p);
deleted += 1;
}
}
}
}
let fmt_failures = run_rustfmt_on_emitted(&inv, out);
eprintln!(
"emit: {} files in {} ({} stage(s), {} stub(s){}{})",
inv.entries.len(),
out.display(),
inv.entries
.iter()
.map(|e| e.stage.as_str())
.collect::<std::collections::HashSet<_>>()
.len(),
inv.entries.iter().filter(|e| e.stub).count(),
if deleted > 0 {
format!(", {deleted} stale file(s) removed")
} else {
String::new()
},
if fmt_failures > 0 {
format!(", {fmt_failures} rustfmt warning(s)")
} else {
String::new()
},
);
Ok(())
}
#[derive(Debug)]
struct Divergence {
kind: &'static str,
path: String,
}
fn compare_emission(staged: &Path, out: &Path, new_inv: &Inventory) -> Result<Vec<Divergence>> {
let mut diffs = Vec::new();
for entry in &new_inv.entries {
let staged_path = staged.join(&entry.path);
let out_path = out.join(&entry.path);
let staged_bytes = std::fs::read(&staged_path).ok();
let out_bytes = std::fs::read(&out_path).ok();
match (staged_bytes, out_bytes) {
(Some(_), None) => diffs.push(Divergence {
kind: "missing in <out>",
path: entry.path.clone(),
}),
(Some(s), Some(o)) if s != o => diffs.push(Divergence {
kind: "content differs",
path: entry.path.clone(),
}),
_ => {}
}
}
if let Ok(prev) = read_inventory(out) {
let new_paths: std::collections::HashSet<&str> =
new_inv.entries.iter().map(|e| e.path.as_str()).collect();
for old_entry in &prev.entries {
if !new_paths.contains(old_entry.path.as_str()) {
diffs.push(Divergence {
kind: "stale in <out>",
path: old_entry.path.clone(),
});
}
}
}
Ok(diffs)
}
fn scan_untracked_against_inventory(out: &Path) -> Result<Vec<String>> {
let inv = match read_inventory(out) {
Ok(i) => i,
Err(_) => {
let mut acc = Vec::new();
collect_files(out, out, &mut acc);
return Ok(acc);
}
};
let known: std::collections::HashSet<String> =
inv.entries.iter().map(|e| e.path.clone()).collect();
let mut acc = Vec::new();
collect_files(out, out, &mut acc);
let untracked: Vec<String> = acc
.into_iter()
.filter(|p| p != ".rlvgl-app-manifest.json" && !known.contains(p))
.collect();
Ok(untracked)
}
fn collect_files(root: &Path, dir: &Path, acc: &mut Vec<String>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_files(root, &path, acc);
} else if let Ok(rel) = path.strip_prefix(root) {
acc.push(rel.to_string_lossy().into_owned());
}
}
}
fn read_inventory(out: &Path) -> Result<Inventory> {
let path = out.join(".rlvgl-app-manifest.json");
let body =
std::fs::read_to_string(&path).map_err(|e| anyhow!("read {}: {e}", path.display()))?;
let inv: Inventory =
serde_json::from_str(&body).map_err(|e| anyhow!("parse {}: {e}", path.display()))?;
Ok(inv)
}
struct StagingDir {
path: PathBuf,
}
impl StagingDir {
fn new() -> Result<Self> {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let pid = std::process::id();
let path = std::env::temp_dir().join(format!("rlvgl-app-check-{pid}-{nanos}"));
std::fs::create_dir_all(&path)?;
Ok(Self { path })
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for StagingDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn run_rustfmt_on_emitted(inv: &Inventory, out: &Path) -> usize {
let mut failures = 0;
for e in &inv.entries {
if !e.path.ends_with(".rs") {
continue;
}
let p = out.join(&e.path);
let status = std::process::Command::new("rustfmt")
.args(["--edition", "2024", "--quiet"])
.arg(&p)
.status();
match status {
Ok(s) if s.success() => {}
Ok(s) => {
eprintln!("rustfmt: {} exited with {}", p.display(), s);
failures += 1;
}
Err(e) => {
eprintln!("rustfmt: failed to invoke for {}: {e}", p.display());
failures += 1;
}
}
}
failures
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct SmSelfManifest {
pub tool: String,
pub version: String,
pub source: String,
pub files: Vec<SmFile>,
pub state_set: Vec<String>,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct SmFile {
pub path: String,
pub hash: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct InventoryEntry {
pub path: String,
pub stage: String,
pub hash: String,
#[serde(default)]
pub stub: bool,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Inventory {
pub manifest: String,
pub schema: String,
pub orchestrator: String,
pub generated_at: String,
pub entries: Vec<InventoryEntry>,
}
impl Inventory {
fn new(manifest_path: &Path, schema: &str) -> Self {
Self {
manifest: manifest_path.display().to_string(),
schema: schema.to_string(),
orchestrator: format!("rlvgl-creator app from-yaml (APP-02b)"),
generated_at: chrono_iso8601_today(),
entries: Vec::new(),
}
}
fn scratch_for(&self) -> Self {
Self {
manifest: self.manifest.clone(),
schema: self.schema.clone(),
orchestrator: self.orchestrator.clone(),
generated_at: self.generated_at.clone(),
entries: Vec::new(),
}
}
}
fn chrono_iso8601_today() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let days = (secs / 86_400) as i64;
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{:04}-{:02}-{:02}", y, m, d)
}
pub type BspGenFn =
fn(vendor: &str, board: &str, chip: Option<&str>, out_dir: &Path) -> Result<String>;
pub struct Orchestrator {
manifest: Manifest,
manifest_dir: PathBuf,
workspace_root: PathBuf,
out: PathBuf,
bsp_gen: Option<BspGenFn>,
jobs: usize,
}
impl Orchestrator {
pub fn new(
manifest: Manifest,
manifest_dir: PathBuf,
workspace_root: PathBuf,
out: PathBuf,
) -> Self {
Self {
manifest,
manifest_dir,
workspace_root,
out,
bsp_gen: None,
jobs: 1,
}
}
pub fn with_bsp_gen(mut self, f: BspGenFn) -> Self {
self.bsp_gen = Some(f);
self
}
pub fn with_jobs(mut self, n: usize) -> Self {
self.jobs = n.max(1);
self
}
pub fn run(&mut self) -> Result<Inventory> {
std::fs::create_dir_all(&self.out)
.map_err(|e| anyhow!("create out {}: {e}", self.out.display()))?;
std::fs::create_dir_all(self.out.join("src"))?;
let manifest_path_str = self.manifest_dir.join("app.yaml").display().to_string();
let mut inv = Inventory::new(Path::new(&manifest_path_str), &self.manifest.schema);
let generator = self
.manifest
.target
.generator
.as_deref()
.unwrap_or("creator-bsp-pac");
let needs_bsp = generator == "creator-bsp-pac";
let needs_asset = !self.manifest.assets.is_empty();
let needs_sm = self.manifest.state_machine.is_some();
let needs_i18n = self.manifest.i18n.is_some();
let needs_theme = self.manifest.theme.is_some();
if self.jobs <= 1 {
if needs_bsp {
self.emit_bsp_gen(&mut inv)?;
}
if needs_asset {
self.emit_asset_pipeline(&mut inv)?;
}
if needs_sm {
let sm_self = self.emit_sm_vendored(&mut inv)?;
self.cross_validate_sm(&sm_self)?;
}
if needs_i18n {
self.emit_i18n(&mut inv)?;
}
if needs_theme {
self.emit_theme(&mut inv)?;
}
} else {
self.run_stage3_parallel(
&mut inv,
needs_bsp,
needs_asset,
needs_sm,
needs_i18n,
needs_theme,
)?;
}
if !self.manifest.screens.is_empty() {
self.emit_layouts(&mut inv)?;
}
self.emit_cargo_toml(&mut inv)?;
self.emit_app_rs(&mut inv)?;
self.emit_main_rs(&mut inv)?;
if self.manifest.target.prong == "zephyr" {
self.emit_zephyr_project(&mut inv)?;
}
self.emit_readme(&mut inv)?;
self.write_inventory(&inv)?;
Ok(inv)
}
fn emit(
&self,
rel: impl AsRef<Path>,
bytes: &[u8],
inv: &mut Inventory,
stage: &str,
stub: bool,
) -> Result<()> {
let rel = rel.as_ref();
let abs = self.out.join(rel);
if let Some(parent) = abs.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&abs, bytes)?;
let hash = blake3::hash(bytes).to_hex().to_string();
inv.entries.push(InventoryEntry {
path: rel.to_string_lossy().into_owned(),
stage: stage.to_string(),
hash: format!("blake3:{hash}"),
stub,
});
Ok(())
}
fn run_stage3_parallel(
&self,
inv: &mut Inventory,
needs_bsp: bool,
needs_asset: bool,
needs_sm: bool,
needs_i18n: bool,
needs_theme: bool,
) -> Result<()> {
let mut bsp_inv = inv.scratch_for();
let mut asset_inv = inv.scratch_for();
let mut sm_inv = inv.scratch_for();
let mut i18n_inv = inv.scratch_for();
let mut theme_inv = inv.scratch_for();
let mut sm_self_holder: Option<SmSelfManifest> = None;
let stages = self.jobs.max(2).min(5);
let _ = stages;
std::thread::scope(|s| -> Result<()> {
let mut handles: Vec<std::thread::ScopedJoinHandle<'_, Result<()>>> = Vec::new();
if needs_bsp {
handles.push(s.spawn(|| self.emit_bsp_gen(&mut bsp_inv)));
}
if needs_asset {
handles.push(s.spawn(|| self.emit_asset_pipeline(&mut asset_inv)));
}
if needs_sm {
let sm_inv_ref = &mut sm_inv;
let sm_self_ref = &mut sm_self_holder;
handles.push(s.spawn(|| -> Result<()> {
let sm_self = self.emit_sm_vendored(sm_inv_ref)?;
*sm_self_ref = Some(sm_self);
Ok(())
}));
}
if needs_i18n {
handles.push(s.spawn(|| self.emit_i18n(&mut i18n_inv)));
}
if needs_theme {
handles.push(s.spawn(|| self.emit_theme(&mut theme_inv)));
}
for h in handles {
h.join()
.map_err(|e| anyhow!("stage-3 thread panicked: {e:?}"))??;
}
Ok(())
})?;
if needs_bsp {
inv.entries.append(&mut bsp_inv.entries);
}
if needs_asset {
inv.entries.append(&mut asset_inv.entries);
}
if needs_sm {
inv.entries.append(&mut sm_inv.entries);
let sm_self = sm_self_holder
.as_ref()
.ok_or_else(|| anyhow!("SM-gen thread joined without producing a self-manifest"))?;
self.cross_validate_sm(sm_self)?;
}
if needs_i18n {
inv.entries.append(&mut i18n_inv.entries);
}
if needs_theme {
inv.entries.append(&mut theme_inv.entries);
}
Ok(())
}
fn emit_bsp_gen(&self, inv: &mut Inventory) -> Result<()> {
let board = self.manifest.target.board.as_str();
let vendor = self.manifest.target.vendor.as_str();
let chip = self.manifest.target.chip.as_deref();
let Some(render) = self.bsp_gen else {
return self.emit_bsp_gen_stub_only(vendor, board, inv);
};
let staging = StagingDir::new()?;
let board_stem = render(vendor, board, chip, staging.path())?;
let board_dir = staging.path().join(&board_stem);
for child in [
"board.rs",
"clocks.rs",
"io_mux.rs",
"pac.rs",
"peripherals.rs",
] {
let src = board_dir.join(child);
let bytes = std::fs::read(&src).map_err(|e| {
anyhow!(
"BSP-gen produced no '{child}' under {} (vendor={vendor}, \
board={board}): {e}",
board_dir.display()
)
})?;
self.emit(
Path::new("src/bsp_generated").join(child),
&bytes,
inv,
"bsp-gen",
false,
)?;
}
let mod_rs = format!(
"// SPDX-License-Identifier: MIT\n\
//!\n\
//! Generated BSP for vendor={vendor} board={board}, wrapped as a child\n\
//! module per docs/app-schema/02-generator-pipeline.md §7.2. The five\n\
//! sibling files (`board.rs`, `clocks.rs`, `io_mux.rs`, `pac.rs`,\n\
//! `peripherals.rs`) are emitted byte-for-byte from\n\
//! `rlvgl-creator bsp from-yaml --vendor {vendor} --board {board}`.\n\
//!\n\
//! Regenerate via `rlvgl-creator app from-yaml`; see the parent\n\
//! README for the manifest path.\n\
\n\
#![allow(dead_code)]\n\
\n\
pub mod board;\n\
pub mod clocks;\n\
pub mod io_mux;\n\
pub mod pac;\n\
pub mod peripherals;\n\
\n\
pub use pac::init;\n",
);
self.emit(
"src/bsp_generated/mod.rs",
mod_rs.as_bytes(),
inv,
"bsp-gen",
false,
)
}
fn emit_bsp_gen_stub_only(&self, vendor: &str, board: &str, inv: &mut Inventory) -> Result<()> {
let body = format!(
"// SPDX-License-Identifier: MIT\n\
//\n\
// src/bsp_generated/mod.rs (orchestrator stub — no BspGenFn wired).\n\
//\n\
// The orchestrator was constructed without a BSP-gen callback. The\n\
// production CLI (`rlvgl-creator app from-yaml`) wires this through\n\
// to the chipdb renderers; tests that include `app.rs` directly\n\
// bypass it and land here. vendor={vendor} board={board}.\n\
\n\
pub fn init() {{}}\n",
);
self.emit(
"src/bsp_generated/mod.rs",
body.as_bytes(),
inv,
"bsp-gen",
true,
)
}
fn emit_asset_pipeline(&self, inv: &mut Inventory) -> Result<()> {
let mut index_lines: Vec<String> = vec![
"// SPDX-License-Identifier: MIT".to_string(),
"//".to_string(),
"// src/assets_generated.rs (asset-pipeline emission, APP-02b).".to_string(),
"// Per chapter 02 §7.3: file-copy at v0; converter pipeline".to_string(),
"// integration deferred to APP-02c.".to_string(),
"".to_string(),
];
for asset in &self.manifest.assets {
let src = self.manifest_dir.join(&asset.source);
let src_canon = lexical_normalise(&src);
let ws_canon = lexical_normalise(&self.workspace_root);
if !src_canon.starts_with(&ws_canon) {
bail!(
"asset '{}' source escapes workspace: {}",
asset.id,
asset.source.display()
);
}
let bytes = std::fs::read(&src_canon).map_err(|e| {
anyhow!(
"read asset '{}' from {}: {e}",
asset.id,
src_canon.display()
)
})?;
let ext = asset
.source
.extension()
.and_then(|s| s.to_str())
.unwrap_or("bin");
let out_name = format!("{}.{ext}", asset.id);
self.emit(
Path::new("assets").join(&out_name),
&bytes,
inv,
"asset-pipeline",
false,
)?;
let const_name = ident_upper(&asset.id);
index_lines.push(format!(
"/// {class} asset bound at build time.",
class = asset.class
));
index_lines.push(format!(
"pub static {const_name}: &[u8] = include_bytes!(\"../assets/{out_name}\");",
));
index_lines.push(String::new());
}
index_lines.push("pub mod meta {".to_string());
for asset in &self.manifest.assets {
index_lines.push(format!(
" pub const {}_CLASS: &str = {:?};",
ident_upper(&asset.id),
asset.class
));
if let Some(ref pref) = asset.palette_ref {
index_lines.push(format!(
" pub const {}_PALETTE_REF: Option<&str> = Some({:?});",
ident_upper(&asset.id),
pref
));
} else {
index_lines.push(format!(
" pub const {}_PALETTE_REF: Option<&str> = None;",
ident_upper(&asset.id)
));
}
}
index_lines.push("}".to_string());
let index_body = index_lines.join("\n") + "\n";
self.emit(
"src/assets_generated.rs",
index_body.as_bytes(),
inv,
"asset-pipeline",
false,
)
}
fn emit_sm_vendored(&self, inv: &mut Inventory) -> Result<SmSelfManifest> {
let sm = self
.manifest
.state_machine
.as_ref()
.expect("emit_sm_vendored called without state_machine:");
let vendored = lexical_normalise(&self.manifest_dir.join(&sm.vendored_crate));
let self_manifest_path = vendored.join(".mcp-statechart-manifest.json");
let self_manifest_text = std::fs::read_to_string(&self_manifest_path).map_err(|e| {
anyhow!(
"read SM-gen self-manifest at {}: {e}",
self_manifest_path.display()
)
})?;
let self_manifest: SmSelfManifest =
serde_json::from_str(&self_manifest_text).map_err(|e| {
anyhow!(
"parse SM-gen self-manifest at {}: {e}",
self_manifest_path.display()
)
})?;
let has_vectors = self_manifest
.files
.iter()
.any(|f| f.path == "src/vectors.rs");
if sm.verification_vectors && !has_vectors {
bail!(
"SM-gen self-manifest at {} is missing src/vectors.rs but state_machine.verification_vectors is true (chapter 04 §7.4)",
self_manifest_path.display()
);
}
if !sm.verification_vectors && has_vectors {
bail!(
"SM-gen self-manifest at {} carries src/vectors.rs but state_machine.verification_vectors is false (chapter 04 §7.4)",
self_manifest_path.display()
);
}
if vendored.join("Cargo.toml").is_file() {
return Ok(self_manifest);
}
for f in &self_manifest.files {
let rel = f.path.strip_prefix("src/").ok_or_else(|| {
anyhow!(
"SM-gen self-manifest file path '{}' is not under src/ (chapter 04 §5.4 inline form)",
f.path
)
})?;
let src = vendored.join(&f.path);
let bytes = std::fs::read(&src)
.map_err(|e| anyhow!("read SM-gen output {}: {e}", src.display()))?;
self.emit(
Path::new("src/state_machine").join(rel),
&bytes,
inv,
"sm-gen",
false,
)?;
}
let mut mod_rs = String::from(
"// SPDX-License-Identifier: MIT\n\
//!\n\
//! Vendored state machine, wrapped as a child module per\n\
//! docs/app-schema/04-state-machine-boundary.md §5.4.\n\
//! The sibling files (`states.rs` and optionally `vectors.rs`)\n\
//! are copied byte-for-byte from the SM-gen vendored output\n\
//! at `state_machine.vendored_crate` per chapter 04 §5.3.\n\
//!\n\
//! Regenerate the upstream SM crate by running\n\
//! `mcp-statechart` against the SCXML at\n\
//! `state_machine.source`; then re-run\n\
//! `rlvgl-creator app from-yaml`.\n\
\n\
#![allow(dead_code)]\n\
\n\
pub mod states;\n",
);
if has_vectors {
mod_rs.push_str("\n#[cfg(test)]\npub mod vectors;\n");
}
self.emit(
"src/state_machine/mod.rs",
mod_rs.as_bytes(),
inv,
"sm-gen",
false,
)?;
Ok(self_manifest)
}
fn cross_validate_sm(&self, sm_self: &SmSelfManifest) -> Result<()> {
let state_set: std::collections::HashSet<&str> =
sm_self.state_set.iter().map(String::as_str).collect();
for s in &self.manifest.screens {
if let Some(state) = &s.state {
if !state_set.contains(state.as_str()) {
bail!(
"chapter 04 §6 CV-1: screen '{}' references state '{}' which is not in the SM's emitted state_set {:?}",
s.id,
state,
sm_self.state_set
);
}
}
}
Ok(())
}
fn emit_i18n(&self, inv: &mut Inventory) -> Result<()> {
let i18n = self
.manifest
.i18n
.as_ref()
.expect("emit_i18n called without i18n:");
let bundle_dir = lexical_normalise(&self.manifest_dir.join(&i18n.bundle_dir));
if !bundle_dir.is_dir() {
bail!(
"i18n.bundle_dir does not resolve to a directory: {} (chapter 02 §7.5)",
bundle_dir.display()
);
}
let mut bundles: Vec<(String, Vec<(String, String)>)> = Vec::new();
let mut dir_entries: Vec<std::path::PathBuf> = std::fs::read_dir(&bundle_dir)
.map_err(|e| anyhow!("read i18n.bundle_dir {}: {e}", bundle_dir.display()))?
.filter_map(|r| r.ok())
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("json"))
.collect();
dir_entries.sort();
for path in dir_entries {
let locale = path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow!("i18n bundle has unreadable file stem: {}", path.display()))?
.to_string();
let text = std::fs::read_to_string(&path)
.map_err(|e| anyhow!("read i18n bundle {}: {e}", path.display()))?;
let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&text)
.map_err(|e| anyhow!("parse i18n bundle {}: {e}", path.display()))?;
let mut pairs: Vec<(String, String)> = map
.into_iter()
.map(|(k, v)| {
let s = v
.as_str()
.ok_or_else(|| {
anyhow!("i18n bundle {} key '{}' is not a string", path.display(), k)
})?
.to_string();
Ok::<_, anyhow::Error>((k, s))
})
.collect::<Result<Vec<_>>>()?;
pairs.sort_by(|a, b| a.0.cmp(&b.0));
bundles.push((locale, pairs));
}
if bundles.is_empty() {
bail!(
"i18n.bundle_dir {} contains no <locale>.json files (chapter 02 §7.5)",
bundle_dir.display()
);
}
let key_union: std::collections::BTreeSet<&str> = bundles
.iter()
.flat_map(|(_, kv)| kv.iter().map(|(k, _)| k.as_str()))
.collect();
for (locale, kv) in &bundles {
let have: std::collections::BTreeSet<&str> =
kv.iter().map(|(k, _)| k.as_str()).collect();
for missing in key_union.difference(&have) {
eprintln!(
"i18n: warning: locale '{locale}' is missing key '{missing}' (will fall through to the key as the literal string)"
);
}
}
let mut body = String::new();
body.push_str("// SPDX-License-Identifier: MIT\n");
body.push_str("//\n");
body.push_str("// src/i18n_generated.rs (i18n generator emission, APP-02f).\n");
body.push_str("// Per chapter 02 §7.5: one match arm per (locale, key) pair drawn\n");
let bundle_list = bundles
.iter()
.map(|(l, _)| format!("{l}.json"))
.collect::<Vec<_>>()
.join(", ");
body.push_str(&format!(
"// from `{bundle_list}` files in `{}`.\n",
i18n.bundle_dir.display()
));
body.push('\n');
body.push_str(&format!(
"pub const DEFAULT_LOCALE: &str = {:?};\n\n",
i18n.default_locale
));
body.push_str("pub fn t(key: &str, locale: &str) -> &'static str {\n");
body.push_str(" match (locale, key) {\n");
for (locale, kv) in &bundles {
for (k, v) in kv {
body.push_str(&format!(" ({:?}, {:?}) => {:?},\n", locale, k, v));
}
}
body.push_str(" _ => key,\n");
body.push_str(" }\n");
body.push_str("}\n");
self.emit("src/i18n_generated.rs", body.as_bytes(), inv, "i18n", false)
}
fn emit_theme(&self, inv: &mut Inventory) -> Result<()> {
let theme = self
.manifest
.theme
.as_ref()
.expect("emit_theme called without theme:");
let source = lexical_normalise(&self.manifest_dir.join(&theme.source));
let text = std::fs::read_to_string(&source)
.map_err(|e| anyhow!("read theme.source {}: {e}", source.display()))?;
let json: serde_json::Value = serde_json::from_str(&text)
.map_err(|e| anyhow!("parse theme.source {}: {e}", source.display()))?;
let mut body = String::new();
body.push_str("// SPDX-License-Identifier: MIT\n");
body.push_str("//\n");
body.push_str(&format!(
"// src/theme.rs (theme-translator emission, APP-02g, format={}).\n",
theme.format
));
body.push_str(&format!(
"// Per chapter 02 §7.6 — consumed from `{}`.\n\n",
theme.source.display()
));
match theme.format.as_str() {
"raw_palette_v1" => {
let map = json.as_object().ok_or_else(|| {
anyhow!(
"raw_palette_v1: theme.source {} must be a flat JSON object",
source.display()
)
})?;
Self::emit_color_module(&mut body, map, &source)?;
}
"chakra_tokens_v1" => {
let root = json.as_object().ok_or_else(|| {
anyhow!(
"chakra_tokens_v1: theme.source {} must be a JSON object",
source.display()
)
})?;
if let Some(colors) = root.get("colors").and_then(|v| v.as_object()) {
Self::emit_color_module(&mut body, colors, &source)?;
}
if let Some(space) = root.get("space").and_then(|v| v.as_object()) {
Self::emit_u16_module(&mut body, "space", space, &source)?;
}
if let Some(radii) = root.get("radii").and_then(|v| v.as_object()) {
Self::emit_u16_module(&mut body, "radii", radii, &source)?;
}
}
other => bail!(
"theme.format '{other}' is not implemented; supported at v0: {:?}",
THEME_FORMATS
),
}
self.emit("src/theme.rs", body.as_bytes(), inv, "theme", false)
}
fn emit_color_module(
body: &mut String,
map: &serde_json::Map<String, serde_json::Value>,
source: &Path,
) -> Result<()> {
let mut pairs: Vec<(String, u32)> = Vec::with_capacity(map.len());
for (k, v) in map {
let s = v.as_str().ok_or_else(|| {
anyhow!(
"theme.source {} colors.{} is not a string",
source.display(),
k
)
})?;
let rgb = parse_rgb_hex(s).map_err(|e| {
anyhow!(
"theme.source {} colors.{} = {:?}: {e}",
source.display(),
k,
s
)
})?;
pairs.push((upper_snake(k), rgb));
}
pairs.sort_by(|a, b| a.0.cmp(&b.0));
body.push_str("pub mod colors {\n");
for (name, rgb) in pairs {
body.push_str(&format!(" pub const {name}: u32 = 0x{rgb:06X};\n"));
}
body.push_str("}\n\n");
Ok(())
}
fn emit_u16_module(
body: &mut String,
module: &str,
map: &serde_json::Map<String, serde_json::Value>,
source: &Path,
) -> Result<()> {
let mut pairs: Vec<(String, u16)> = Vec::with_capacity(map.len());
for (k, v) in map {
let n = v.as_u64().ok_or_else(|| {
anyhow!(
"theme.source {} {module}.{} is not a non-negative integer",
source.display(),
k
)
})?;
if n > u16::MAX as u64 {
bail!(
"theme.source {} {module}.{} = {n} does not fit in u16",
source.display(),
k
);
}
pairs.push((upper_snake(k), n as u16));
}
pairs.sort_by(|a, b| a.0.cmp(&b.0));
body.push_str(&format!("pub mod {module} {{\n"));
for (name, value) in pairs {
body.push_str(&format!(" pub const {name}: u16 = {value};\n"));
}
body.push_str("}\n\n");
Ok(())
}
fn emit_layouts(&self, inv: &mut Inventory) -> Result<()> {
let mut mod_lines: Vec<String> = vec![
"// SPDX-License-Identifier: MIT".to_string(),
"//".to_string(),
"// src/screens/mod.rs (layout-translator emission, APP-02b).".to_string(),
"".to_string(),
];
for screen in &self.manifest.screens {
match screen.layout_format.as_str() {
"rust_inline_v1" => {
let src = self.manifest_dir.join(&screen.layout);
let src_canon = lexical_normalise(&src);
let ws_canon = lexical_normalise(&self.workspace_root);
if !src_canon.starts_with(&ws_canon) {
bail!(
"screen '{}' layout escapes workspace: {}",
screen.id,
screen.layout.display()
);
}
let bytes = std::fs::read(&src_canon).map_err(|e| {
anyhow!(
"read screen '{}' layout from {}: {e}",
screen.id,
src_canon.display()
)
})?;
let mod_name = ident_module(&screen.id);
let rel = Path::new("src/screens").join(format!("{mod_name}.rs"));
self.emit(rel, &bytes, inv, "layout-translator", false)?;
mod_lines.push(format!("pub mod {mod_name};"));
}
other => {
bail!(
"layout_format '{}' for screen '{}' is not implemented at v0.5. \
Only 'rust_inline_v1' has an implementation; \
'figma_export_v1' and 'uml_widget_v1' are reclassified \
Coupled deferred per docs/app-schema/APP-06-A.md \
(2026-05-04). See APP-06-A §4 for the unfreeze criteria \
on each deferred format.",
other,
screen.id
);
}
}
}
let mod_body = mod_lines.join("\n") + "\n";
self.emit(
"src/screens/mod.rs",
mod_body.as_bytes(),
inv,
"layout-translator",
false,
)
}
fn emit_cargo_toml(&self, inv: &mut Inventory) -> Result<()> {
let mut s = String::new();
s.push_str("# Generated by rlvgl-creator app from-yaml (APP-02c).\n");
s.push_str("# Edits to this file will be overwritten on regeneration.\n");
s.push_str("# Per chapter 02 §5.3 + §9.3, hand edits live outside\n");
s.push_str("# the generated tree.\n\n");
s.push_str("[package]\n");
s.push_str(&format!("name = {:?}\n", self.manifest.name));
let version = self
.manifest
.metadata
.as_ref()
.and_then(|m| m.get(serde_yaml::Value::from("version")))
.and_then(|v| v.as_str())
.unwrap_or("0.1.0");
s.push_str(&format!("version = {:?}\n", version));
s.push_str("edition = \"2024\"\n");
s.push_str("publish = false\n");
if let Some(license) = self
.manifest
.metadata
.as_ref()
.and_then(|m| m.get(serde_yaml::Value::from("license")))
.and_then(|v| v.as_str())
{
s.push_str(&format!("license = {:?}\n", license));
}
s.push('\n');
let prong = self.manifest.target.prong.as_str();
let generator = self
.manifest
.target
.generator
.as_deref()
.unwrap_or("creator-bsp-pac");
let vendor = self.manifest.target.vendor.as_str();
let board = self.manifest.target.board.as_str();
let template = feature_graphs::lookup(prong, generator, vendor, board);
if prong == "zephyr" {
s.push_str("# Zephyr prong: Rust side is a staticlib linked into the\n");
s.push_str("# nested west project at zephyr/. See zephyr/CMakeLists.txt.\n");
s.push_str("[lib]\n");
s.push_str("path = \"src/lib.rs\"\n");
s.push_str("crate-type = [\"staticlib\"]\n\n");
} else {
s.push_str("[[bin]]\n");
s.push_str(&format!("name = {:?}\n", self.manifest.name));
s.push_str("path = \"src/main.rs\"\n");
if let Some(t) = template {
if !t.bin_required_features.is_empty() {
s.push_str(&format!(
"required-features = {:?}\n",
t.bin_required_features
));
}
}
s.push('\n');
if let Some(t) = template {
for bin in t.extra_bins {
s.push_str("[[bin]]\n");
s.push_str(&format!("name = {:?}\n", bin.name));
s.push_str(&format!("path = {:?}\n", bin.path));
if !bin.required_features.is_empty() {
s.push_str(&format!(
"required-features = {:?}\n",
bin.required_features
));
}
s.push('\n');
}
}
}
if !self.manifest.target.features.is_empty() {
s.push_str("[features]\n");
let default_line = match template.map(|t| &t.default_features) {
Some(feature_graphs::DefaultPolicy::Empty) => "default = []".to_string(),
Some(feature_graphs::DefaultPolicy::Explicit(list)) => {
format!("default = {list:?}")
}
Some(feature_graphs::DefaultPolicy::AllManifestFeatures) | None => {
format!("default = {:?}", self.manifest.target.features)
}
};
s.push_str(&default_line);
s.push('\n');
for feat in &self.manifest.target.features {
let expansion = template
.and_then(|t| {
t.feature_expansions
.iter()
.find(|(k, _)| k == feat)
.map(|(_, v)| *v)
})
.unwrap_or(&[]);
s.push_str(&format!("{feat} = {expansion:?}\n"));
}
if let Some(t) = template {
for (name, exp) in t.extra_features {
if self.manifest.target.features.iter().any(|f| f == name) {
continue;
}
s.push_str(&format!("{name} = {exp:?}\n"));
}
}
s.push('\n');
}
s.push_str("[dependencies]\n");
if let Some(c) = &self.manifest.controller {
s.push_str("# Controller library (chapter 01 §5.10 / chapter 02 §7.8).\n");
match (&c.path, &c.version) {
(Some(p), None) => {
let path_str = p.to_string_lossy().into_owned();
if c.features.is_empty() {
s.push_str(&format!("{} = {{ path = {:?} }}\n", c.crate_name, path_str));
} else {
s.push_str(&format!(
"{} = {{ path = {:?}, features = {:?} }}\n",
c.crate_name, path_str, c.features
));
}
}
(None, Some(v)) => {
if c.features.is_empty() {
s.push_str(&format!("{} = {{ version = {:?} }}\n", c.crate_name, v));
} else {
s.push_str(&format!(
"{} = {{ version = {:?}, features = {:?} }}\n",
c.crate_name, v, c.features
));
}
}
(None, None) => {
if c.features.is_empty() {
s.push_str(&format!("{} = \"*\"\n", c.crate_name));
} else {
s.push_str(&format!(
"{} = {{ features = {:?} }}\n",
c.crate_name, c.features
));
}
}
(Some(_), Some(_)) => {
unreachable!("validator rejects controller.path + controller.version both set")
}
}
}
if let Some(t) = template {
for dep in t.base_deps {
s.push_str(&format_dep_line(dep));
}
for (cfg, deps) in t.target_cfg_deps {
s.push('\n');
s.push_str(&format!("[target.{cfg:?}.dependencies]\n"));
for dep in *deps {
s.push_str(&format_dep_line(dep));
}
}
if !t.build_deps.is_empty() {
s.push('\n');
s.push_str("[build-dependencies]\n");
for dep in t.build_deps {
s.push_str(&format_dep_line(dep));
}
}
} else {
s.push_str(&format!(
"\n# TODO(template-tuning): no APP-05 feature-graph template\n\
# registered for (prong={prong}, generator={generator}, vendor={vendor},\n\
# board={board}). Add one in src/bin/creator/app/feature_graphs.rs.\n",
));
}
self.emit("Cargo.toml", s.as_bytes(), inv, "scaffold", false)
}
fn emit_app_rs(&self, inv: &mut Inventory) -> Result<()> {
let mut s = String::new();
s.push_str("// SPDX-License-Identifier: MIT\n");
s.push_str("//\n");
s.push_str("// src/app.rs — wiring contract per chapter 00 §7 / chapter 02 §7.8.\n");
s.push_str("// Generated by rlvgl-creator app from-yaml (APP-02c).\n");
s.push_str("\n");
s.push_str("// TODO(user): replace with the real BSP type from bsp_generated/\n");
s.push_str("// or rlvgl-platform once the per-prong main glue is wired up.\n");
s.push_str("pub type Bsp = ();\n");
s.push_str("pub type Inputs = ();\n");
s.push_str("pub type Outputs = ();\n");
s.push_str("\n");
if let Some(c) = &self.manifest.controller {
let crate_ident = c.crate_name.replace('-', "_");
s.push_str(&format!(
"use {crate_ident}::{{DiscoCapabilities, DiscoController}};\n\n"
));
s.push_str(
"/// Application wiring shim around the manifest-named controller library.\n",
);
s.push_str("pub struct App {\n");
s.push_str(" #[allow(dead_code)]\n");
s.push_str(" controller: DiscoController,\n");
s.push_str("}\n\n");
s.push_str("impl App {\n");
s.push_str(" pub fn new(_bsp: Bsp) -> Self {\n");
let caps = c.capabilities.as_deref().unwrap_or("stm32h747i_disco");
s.push_str(&format!(
" let _caps = DiscoCapabilities::{caps}();\n"
));
s.push_str(" // TODO(user): replace with `DiscoController::new(bsp, caps)`\n");
s.push_str(" // once the controller's real constructor signature is wired.\n");
s.push_str(" Self {\n");
s.push_str(" controller: unimplemented!(\"controller construction — wire BSP type and capabilities preset\"),\n");
s.push_str(" }\n");
s.push_str(" }\n\n");
s.push_str(" pub fn tick(&mut self, _now: std::time::Instant, _inputs: Inputs) -> Outputs {\n");
s.push_str(" // TODO(user): delegate to controller's per-frame entry point.\n");
s.push_str(" }\n");
s.push_str("}\n");
} else {
s.push_str("/// Application wiring shim. No controller library declared in the\n");
s.push_str("/// manifest; fill `App::tick` with the per-frame body by hand.\n");
s.push_str("pub struct App;\n\n");
s.push_str("impl App {\n");
s.push_str(" pub fn new(_bsp: Bsp) -> Self {\n");
s.push_str(" Self\n");
s.push_str(" }\n\n");
s.push_str(" pub fn tick(&mut self, _now: std::time::Instant, _inputs: Inputs) -> Outputs {\n");
s.push_str(" // TODO(user): per-frame body.\n");
s.push_str(" }\n");
s.push_str("}\n");
}
self.emit("src/app.rs", s.as_bytes(), inv, "scaffold", false)
}
fn emit_main_rs(&self, inv: &mut Inventory) -> Result<()> {
let prong = self.manifest.target.prong.as_str();
let s = match prong {
"linux" => self.linux_main_template(),
"bare_metal" => self.bare_metal_main_template(),
"freertos" => self.freertos_main_template(),
"zephyr" => self.zephyr_lib_template(),
other => bail!("unknown prong '{other}' — validator should have caught this"),
};
let path = if prong == "zephyr" {
"src/lib.rs"
} else {
"src/main.rs"
};
self.emit(path, s.as_bytes(), inv, "scaffold", false)
}
fn linux_main_template(&self) -> String {
let mut s = String::from("// SPDX-License-Identifier: MIT\n");
s.push_str("//\n// src/main.rs — linux prong template per chapter 02 §8.1.\n");
s.push_str("// Generated by rlvgl-creator app from-yaml (APP-02c).\n\n");
s.push_str("mod app;\nmod screens;\n\n");
s.push_str("fn main() -> std::io::Result<()> {\n");
s.push_str(" // TODO(user): real BSP init via rlvgl-platform/linux_fbdev.\n");
s.push_str(" let bsp: app::Bsp = ();\n");
s.push_str(" let mut app_state = app::App::new(bsp);\n");
s.push_str(" let frame = std::time::Duration::from_millis(16);\n");
s.push_str(" let mut next = std::time::Instant::now();\n");
s.push_str(" loop {\n");
s.push_str(" let inputs: app::Inputs = (); // TODO: poll evdev / playit.\n");
s.push_str(" app_state.tick(std::time::Instant::now(), inputs);\n");
s.push_str(" next += frame;\n");
s.push_str(" std::thread::sleep(next.saturating_duration_since(std::time::Instant::now()));\n");
s.push_str(" }\n");
s.push_str("}\n");
s
}
fn bare_metal_main_template(&self) -> String {
let mut s = String::from("// SPDX-License-Identifier: MIT\n");
s.push_str("//\n// src/main.rs — bare_metal prong template per chapter 02 §8.2.\n");
s.push_str("// Generated by rlvgl-creator app from-yaml (APP-02c).\n\n");
s.push_str("#![no_std]\n#![no_main]\n\n");
s.push_str("mod app;\nmod screens;\n\n");
s.push_str("// TODO(user): pull in cortex-m-rt (Cortex-M targets) or\n");
s.push_str("// esp-riscv-rt (ESP32-C3/-C6) per the manifest's target.vendor\n");
s.push_str("// and add `#[entry]` here. Stub kept compile-clean to make the\n");
s.push_str("// scaffold reviewable in PR diff form before the runtime crate\n");
s.push_str("// is wired in.\n");
s.push_str("\n#[panic_handler]\n");
s.push_str("fn panic(_info: &core::panic::PanicInfo) -> ! { loop {} }\n\n");
s.push_str("fn _entry_template() -> ! {\n");
s.push_str(" let bsp: app::Bsp = ();\n");
s.push_str(" let mut app_state = app::App::new(bsp);\n");
s.push_str(" loop {\n");
s.push_str(" let inputs: app::Inputs = (); // TODO: poll BSP inputs.\n");
s.push_str(" // TODO(user): drive present + wait_for_frame from the\n");
s.push_str(" // BSP's vsync / ERIF / SysTick (chapter 02 §8.2).\n");
s.push_str(" let now = std::time::Instant::now;\n");
s.push_str(" let _ = (app_state.tick, now, inputs);\n");
s.push_str(" }\n");
s.push_str("}\n");
s
}
fn freertos_main_template(&self) -> String {
let mut s = String::from("// SPDX-License-Identifier: MIT\n");
s.push_str("//\n// src/main.rs — freertos prong template per chapter 02 §8.3.\n");
s.push_str("// Generated by rlvgl-creator app from-yaml (APP-02c).\n\n");
s.push_str("#![no_std]\n#![no_main]\n\n");
s.push_str("mod app;\nmod screens;\n\n");
s.push_str("// TODO(user): FreeRTOS task wiring per chapter 02 §8.3.\n");
s.push_str("// Required tasks (matching examples/stm32h747i-disco/'s\n");
s.push_str("// hand-written FreeRTOS port):\n");
s.push_str("// - present_task: phase-locked to ERIF, calls bsp.present()\n");
s.push_str("// - render_task: drives App::tick once per frame\n");
s.push_str("// - input_task: polls touch / joystick / playit\n");
s.push_str("// - playit_task (optional): UART command protocol\n");
s.push_str("// Communication via FreeRTOS queues; App::tick runs in render_task.\n");
s.push_str("\n#[panic_handler]\n");
s.push_str("fn panic(_info: &core::panic::PanicInfo) -> ! { loop {} }\n\n");
s.push_str("fn _render_task_body() {\n");
s.push_str(" let bsp: app::Bsp = ();\n");
s.push_str(" let mut app_state = app::App::new(bsp);\n");
s.push_str(" let inputs: app::Inputs = ();\n");
s.push_str(" // TODO(user): app_state.tick(now_cycles(), inputs);\n");
s.push_str(" let _ = (app_state, inputs);\n");
s.push_str("}\n");
s
}
fn zephyr_lib_template(&self) -> String {
let mut s = String::from("// SPDX-License-Identifier: MIT\n");
s.push_str("//\n// src/lib.rs — zephyr prong staticlib entry per chapter 02 §8.4.\n");
s.push_str("// Generated by rlvgl-creator app from-yaml (APP-02c).\n\n");
s.push_str("#![no_std]\n\n");
s.push_str("pub mod app;\n");
s.push_str("pub mod screens;\n\n");
s.push_str("/// C-callable entry point invoked from zephyr/src/main.c.\n");
s.push_str("#[unsafe(no_mangle)]\n");
s.push_str("pub extern \"C\" fn rlvgl_init() -> i32 {\n");
s.push_str(" let bsp: app::Bsp = ();\n");
s.push_str(" let mut app_state = app::App::new(bsp);\n");
s.push_str(" loop {\n");
s.push_str(" let inputs: app::Inputs = ();\n");
s.push_str(" // TODO(user): app_state.tick(now_cycles(), inputs);\n");
s.push_str(" // TODO(user): bsp.present(); bsp.wait_for_frame();\n");
s.push_str(" let _ = (&mut app_state, inputs);\n");
s.push_str(" }\n");
s.push_str("}\n\n");
s.push_str("#[panic_handler]\n");
s.push_str("fn panic(_info: &core::panic::PanicInfo) -> ! { loop {} }\n");
s
}
fn emit_zephyr_project(&self, inv: &mut Inventory) -> Result<()> {
let cmake = format!(
"# Generated by rlvgl-creator app from-yaml (APP-02c).\n\
# Per docs/app-schema/02-generator-pipeline.md §8.4.\n\
\n\
cmake_minimum_required(VERSION 3.20.0)\n\
find_package(Zephyr REQUIRED HINTS $ENV{{ZEPHYR_BASE}})\n\
project({name} C)\n\
\n\
target_sources(app PRIVATE src/main.c)\n\
\n\
# The Rust staticlib is built separately via:\n\
# cargo build -p {name} --features {features}\n\
# then linked here.\n\
set(RLVGL_RUST_LIB\n\
\x20 ${{CMAKE_CURRENT_SOURCE_DIR}}/../target/<triple>/release/lib{lib_name}.a)\n\
\n\
if(EXISTS ${{RLVGL_RUST_LIB}})\n\
\x20 target_link_libraries(app PUBLIC ${{RLVGL_RUST_LIB}})\n\
else()\n\
\x20 message(FATAL_ERROR \"Rust staticlib not found at ${{RLVGL_RUST_LIB}}\")\n\
endif()\n",
name = self.manifest.name,
features = self.manifest.target.features.join(","),
lib_name = self.manifest.name.replace('-', "_"),
);
self.emit(
"zephyr/CMakeLists.txt",
cmake.as_bytes(),
inv,
"scaffold",
false,
)?;
let prj_conf = "# Generated by rlvgl-creator app from-yaml (APP-02c).\n\
# Per docs/app-schema/02-generator-pipeline.md §8.4.\n\
# Per-board hand-tuned overrides (e.g. CONFIG_MAIN_STACK_SIZE)\n\
# remain a hand-edit concern at v0 (see docs/app-schema/03-round-trip.md\n\
# §6.12 DEFER). APP-02d --check mode will surface divergence.\n\
\n\
# Display + input baseline.\n\
CONFIG_DISPLAY=y\n\
CONFIG_INPUT=y\n\
\n\
# Console + logging.\n\
CONFIG_SERIAL=y\n\
CONFIG_UART_INTERRUPT_DRIVEN=y\n\
CONFIG_CONSOLE=y\n\
CONFIG_UART_CONSOLE=y\n\
CONFIG_LOG=y\n\
CONFIG_LOG_DEFAULT_LEVEL=3\n\
\n\
# FPU enabled by default for Cortex-M targets that have one.\n\
CONFIG_FPU=y\n\
\n\
# Memory pool baseline; tune per-board if the render loop\n\
# exhausts it.\n\
CONFIG_MAIN_STACK_SIZE=8192\n\
CONFIG_HEAP_MEM_POOL_SIZE=32768\n";
self.emit(
"zephyr/prj.conf",
prj_conf.as_bytes(),
inv,
"scaffold",
false,
)?;
let overlay = "/* Generated by rlvgl-creator app from-yaml (APP-02c).\n\
*\n\
* Per docs/app-schema/02-generator-pipeline.md §8.4. Baseline\n\
* overlay enabling display + console nodes. Per-board\n\
* specifics (e.g. DSI adapted command mode, FT5336 touch\n\
* polling period) remain a hand-edit concern at v0; see\n\
* docs/app-schema/03-round-trip.md §6.12 DEFER.\n\
*/\n\
\n\
/ {\n\
\x20 chosen {\n\
\x20 /* TODO(user): zephyr,display = ...; per board. */\n\
\x20 /* TODO(user): zephyr,console = ...; per board. */\n\
\x20 };\n\
};\n";
self.emit(
"zephyr/app.overlay",
overlay.as_bytes(),
inv,
"scaffold",
false,
)?;
let main_c = format!(
"/* SPDX-License-Identifier: MIT\n\
*\n\
* zephyr/src/main.c — calls into the Rust staticlib.\n\
* Generated by rlvgl-creator app from-yaml (APP-02c).\n\
*/\n\
\n\
#include <zephyr/kernel.h>\n\
\n\
extern int rlvgl_init(void);\n\
\n\
int main(void) {{\n\
\x20 return rlvgl_init();\n\
}}\n"
);
self.emit(
"zephyr/src/main.c",
main_c.as_bytes(),
inv,
"scaffold",
false,
)
}
fn emit_readme(&self, inv: &mut Inventory) -> Result<()> {
let desc = self
.manifest
.metadata
.as_ref()
.and_then(|m| m.get(serde_yaml::Value::from("description")))
.and_then(|v| v.as_str())
.unwrap_or("rlvgl application generated from app.yaml.");
let body = format!(
"# {name}\n\n\
{desc}\n\n\
Generated by `rlvgl-creator app from-yaml` (APP-02c).\n\n\
## Manifest\n\n\
- Vendor: `{vendor}`\n\
- Board: `{board}`\n\
- Prong: `{prong}`\n\
- Generator: `{gen}`\n\
- Features: {features}\n\n\
## Layout\n\n\
- `src/app.rs` — wiring shim (chapter 00 §7).\n\
- `src/main.rs` — per-prong entry (linux/bare_metal/freertos)\n\
\x20 or `src/lib.rs` (zephyr staticlib).\n\
- `src/screens/` — layout fragments (rust_inline_v1 file copies).\n\
- `src/assets_generated.rs` — `include_bytes!` index over `assets/`.\n\
- `Cargo.toml` — package + controller dependency + manifest features.\n\n\
## Regeneration\n\n\
Edits to generated files are **overwritten** on regeneration\n\
(chapter 02 §9.3). Hand edits live outside the generated tree.\n",
name = self.manifest.name,
desc = desc,
vendor = self.manifest.target.vendor,
board = self.manifest.target.board,
prong = self.manifest.target.prong,
gen = self
.manifest
.target
.generator
.as_deref()
.unwrap_or("creator-bsp-pac"),
features = if self.manifest.target.features.is_empty() {
"(none)".to_string()
} else {
format!("`{}`", self.manifest.target.features.join("`, `"))
},
);
self.emit("README.md", body.as_bytes(), inv, "scaffold", false)
}
fn write_inventory(&self, inv: &Inventory) -> Result<()> {
let path = self.out.join(".rlvgl-app-manifest.json");
let body = serde_json::to_string_pretty(inv)?;
std::fs::write(&path, body.as_bytes())?;
Ok(())
}
}
fn ident_upper(id: &str) -> String {
id.replace('-', "_").to_uppercase()
}
fn ident_module(id: &str) -> String {
id.replace('-', "_")
}
fn upper_snake(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut prev_was_lower = false;
for ch in input.chars() {
if ch.is_ascii_alphanumeric() {
if ch.is_ascii_uppercase() {
if prev_was_lower && !out.ends_with('_') {
out.push('_');
}
out.push(ch);
prev_was_lower = false;
} else if ch.is_ascii_lowercase() {
out.push(ch.to_ascii_uppercase());
prev_was_lower = true;
} else {
out.push(ch);
prev_was_lower = false;
}
} else if !out.is_empty() && !out.ends_with('_') {
out.push('_');
prev_was_lower = false;
}
}
while out.ends_with('_') {
out.pop();
}
out
}
fn parse_rgb_hex(s: &str) -> Result<u32> {
let stripped = s.strip_prefix('#').unwrap_or(s);
let rgb = match stripped.len() {
6 => u32::from_str_radix(stripped, 16).map_err(|e| anyhow!("invalid 6-digit hex: {e}"))?,
3 => {
let n = u32::from_str_radix(stripped, 16)
.map_err(|e| anyhow!("invalid 3-digit hex: {e}"))?;
let r = (n >> 8) & 0xF;
let g = (n >> 4) & 0xF;
let b = n & 0xF;
(r * 0x11 << 16) | (g * 0x11 << 8) | (b * 0x11)
}
_ => bail!(
"expected #rgb or #rrggbb (got {} chars: {:?})",
stripped.len(),
s
),
};
if rgb > 0xFFFFFF {
bail!("hex value 0x{rgb:X} exceeds 24-bit RGB range");
}
Ok(rgb)
}