pub mod codegen;
pub mod manifest;
pub mod project;
pub mod runtime_source;
use anyhow::Result;
use espforge_model::{Artifact, Asset, Board, Meta, Ownership};
use espforge_options::ResolvedOptions;
use std::fs;
use std::path::Path;
pub use project::OverlayFile;
pub use runtime_source::RuntimeSource;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManifestDrift {
pub path: String,
}
impl std::fmt::Display for ManifestDrift {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"refusing to overwrite edited file `{}` (drift detected). \
Restore it or delete it to regenerate.",
self.path
)
}
}
impl std::error::Error for ManifestDrift {}
#[derive(Debug, Clone, Default)]
pub struct Generated {
pub artifacts: Vec<Artifact>,
pub assets: Vec<Asset>,
}
pub fn generate_from_board(
board: &Board,
core: &Meta,
resolved: &ResolvedOptions,
runtime_source: &RuntimeSource,
overlay: &[OverlayFile],
) -> Result<Generated> {
let mut rendered = project::Rendered {
artifacts: codegen::generate(board, core, resolved, runtime_source)?,
assets: Vec::new(),
};
project::layer_overlay(board, resolved, &mut rendered, overlay)?;
finish(rendered, core)
}
fn finish(rendered: project::Rendered, core: &Meta) -> Result<Generated> {
let mut artifacts = rendered.artifacts;
let assets = rendered.assets;
artifacts.push(project::readme(&core.crate_name(), &artifacts, &assets));
let manifest = manifest::emit_manifest(&artifacts, espforge_espgenerate_template::VERSION)?;
artifacts.push(manifest);
Ok(Generated { artifacts, assets })
}
pub fn write(out_dir: &Path, artifacts: &[Artifact]) -> Result<()> {
let prev = manifest::read_prev_manifest(out_dir)?;
for a in artifacts {
if a.ownership == Ownership::NotOwned {
continue;
}
let path = out_dir.join(&a.path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
match a.ownership {
Ownership::SeedOnce => {
if !path.exists() {
fs::write(&path, &a.content)?;
}
}
Ownership::Owned => {
if let Some(prev_sum) = prev.get(&a.path) {
if let Ok(existing) = fs::read_to_string(&path) {
if manifest::checksum(&existing) != *prev_sum && existing != a.content {
return Err(ManifestDrift {
path: a.path.clone(),
}
.into());
}
}
}
fs::write(&path, &a.content)?;
}
Ownership::NotOwned => {}
}
}
Ok(())
}
pub fn write_assets(out_dir: &Path, assets: &[Asset]) -> Result<()> {
for a in assets {
let path = out_dir.join(&a.path);
match a.ownership {
Ownership::NotOwned => continue,
Ownership::SeedOnce if path.exists() => continue,
_ => {}
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&path, &a.bytes)?;
}
Ok(())
}
pub fn write_all(out_dir: &Path, generated: &Generated) -> Result<()> {
write(out_dir, &generated.artifacts)?;
write_assets(out_dir, &generated.assets)
}
#[cfg(test)]
mod tests {
use super::*;
use espforge_model::{Console, ResourceGraph, Runtime};
use espforge_options::{Answers, DerivedInputs};
fn core() -> Meta {
Meta {
name: "hello world".into(),
description: None,
chip: "esp32c3".into(),
runtime: Runtime::Blocking,
console: Console::default(),
}
}
fn board(meta: &Meta) -> Board {
Board {
meta: meta.clone(),
resources: ResourceGraph::default(),
components: Vec::new(),
services: Vec::new(),
}
}
fn generate_with(example: Option<&str>) -> Generated {
let meta = core();
let opts =
espforge_options::resolve(&meta, &Answers::empty(0..0), &DerivedInputs::default())
.unwrap();
let files: Vec<(String, &'static [u8])> = example
.map(|e| {
espforge_examples::find(e)
.unwrap_or_else(|| panic!("no example {e}"))
.files
.iter()
.map(|f| (f.path.clone(), f.contents))
.collect()
})
.unwrap_or_default();
let overlay: Vec<OverlayFile> = files
.iter()
.map(|(path, contents)| OverlayFile { path, contents })
.collect();
generate_from_board(
&board(&meta),
&meta,
&opts,
&RuntimeSource::Published,
&overlay,
)
.unwrap()
}
fn find<'a>(g: &'a Generated, path: &str) -> &'a Artifact {
g.artifacts
.iter()
.find(|a| a.path == path)
.unwrap_or_else(|| panic!("no {path}"))
}
#[test]
fn generate_produces_the_complete_helloworld_project() {
let g = generate_with(None);
let paths: Vec<&str> = g.artifacts.iter().map(|a| a.path.as_str()).collect();
for p in [
"Cargo.toml",
"build.rs",
"src/lib.rs",
"src/generated.rs",
"src/bin/main.rs",
".cargo/config.toml",
".cargo/esp-config.toml",
"rust-toolchain.toml",
"wokwi.toml",
"diagram.json",
"README.txt",
".espforge/manifest.json",
] {
assert!(paths.contains(&p), "missing {p} in {paths:?}");
}
assert!(
find(&g, "Cargo.toml")
.content
.contains("name = \"hello_world\"")
);
assert!(
find(&g, "src/bin/main.rs")
.content
.contains("use hello_world::")
);
assert!(
g.artifacts
.iter()
.all(|a| !a.content.contains("esp-generate"))
);
}
#[test]
fn manifest_records_the_template_version_and_owned_files() {
let g = generate_with(None);
let v: serde_json::Value =
serde_json::from_str(&find(&g, ".espforge/manifest.json").content).unwrap();
assert_eq!(
v["template_version"],
espforge_espgenerate_template::VERSION
);
assert!(v["files"].get("Cargo.toml").is_some());
assert!(v["files"].get("README.txt").is_some());
assert!(v["files"].get(".espforge/manifest.json").is_none());
assert!(
v["files"].get(".cargo/config.toml").is_none(),
"seed-once files are not tracked"
);
}
#[test]
fn example_overlay_flows_through_generate() {
let g = generate_with(Some("04.Communication/i2c_scanner"));
assert!(find(&g, "diagram.json").content.contains("chip-chip"));
assert!(g.assets.iter().any(|a| a.path == "chip.wasm"));
assert!(find(&g, "README.txt").content.contains("chip.wasm"));
assert!(find(&g, "wokwi.toml").content.contains("[[chip]]"));
}
#[test]
fn write_all_creates_files_assets_and_manifest() {
let dir = tempfile::tempdir().unwrap();
write_all(
dir.path(),
&generate_with(Some("04.Communication/i2c_scanner")),
)
.unwrap();
assert!(dir.path().join("src/lib.rs").is_file());
assert!(dir.path().join("README.txt").is_file());
assert!(dir.path().join(".espforge/manifest.json").is_file());
assert!(
fs::read(dir.path().join("chip.wasm"))
.unwrap()
.starts_with(b"\0asm")
);
}
#[test]
fn rewriting_unchanged_output_is_fine() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), &generate_with(None).artifacts).unwrap();
write(dir.path(), &generate_with(None).artifacts).unwrap();
}
#[test]
fn edited_owned_file_is_refused() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), &generate_with(None).artifacts).unwrap();
fs::write(dir.path().join("src/lib.rs"), "// my edit").unwrap();
let err = write(dir.path(), &generate_with(None).artifacts).unwrap_err();
assert!(err.to_string().contains("drift"));
}
#[test]
fn edited_seed_once_file_is_left_alone() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), &generate_with(None).artifacts).unwrap();
fs::write(dir.path().join("rust-toolchain.toml"), "# mine").unwrap();
write(dir.path(), &generate_with(None).artifacts).unwrap();
assert_eq!(
fs::read_to_string(dir.path().join("rust-toolchain.toml")).unwrap(),
"# mine"
);
}
#[test]
fn assets_follow_ownership() {
let dir = tempfile::tempdir().unwrap();
let seed = Asset::seed_once("chip.wasm", vec![0, 97, 115, 109]);
write_assets(dir.path(), std::slice::from_ref(&seed)).unwrap();
fs::write(dir.path().join("chip.wasm"), b"mine").unwrap();
write_assets(dir.path(), std::slice::from_ref(&seed)).unwrap();
assert_eq!(fs::read(dir.path().join("chip.wasm")).unwrap(), b"mine");
}
#[test]
fn old_flat_manifests_still_drive_drift_detection() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join(".espforge")).unwrap();
fs::write(
dir.path().join(".espforge/manifest.json"),
r#"{"src/lib.rs":"0000000000000000"}"#,
)
.unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
fs::write(dir.path().join("src/lib.rs"), "// edited").unwrap();
assert!(write(dir.path(), &generate_with(None).artifacts).is_err());
}
}