use anyhow::{Result, anyhow};
use clap::Args;
use espforge_core::project::{self, ProjectError};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
use espforge_core::pipeline;
use espforge_core::source::{Source, resolve_source as resolve_src};
use super::answers::AnswerArgs;
use super::report;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Stage {
Generate,
#[default]
All,
}
#[derive(Args, Debug, Clone, Default)]
pub struct BuildArgs {
pub file: Option<PathBuf>,
#[arg(long, value_name = "CATEGORY/NAME", conflicts_with = "file")]
pub example: Option<String>,
#[arg(short, long, default_value = "build")]
pub out: PathBuf,
#[arg(skip)]
pub stage: Stage,
#[arg(skip)]
pub release: bool,
#[arg(long)]
pub runtime_path: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
pub write_spec: Option<PathBuf>,
#[command(flatten)]
pub answers: AnswerArgs,
}
#[derive(Debug)]
pub enum Failure {
User,
Internal(anyhow::Error),
}
impl From<anyhow::Error> for Failure {
fn from(e: anyhow::Error) -> Self {
Failure::Internal(e)
}
}
impl From<std::io::Error> for Failure {
fn from(e: std::io::Error) -> Self {
Failure::Internal(e.into())
}
}
fn user(msg: impl std::fmt::Display) -> Failure {
eprintln!("❌ {msg}");
Failure::User
}
pub fn run(args: BuildArgs) -> ExitCode {
match execute(&args) {
Ok(()) => ExitCode::SUCCESS,
Err(Failure::User) => ExitCode::from(1),
Err(Failure::Internal(e)) => {
eprintln!("❌ {e:#}");
ExitCode::from(2)
}
}
}
fn resolve_source(args: &BuildArgs) -> Result<Source, Failure> {
resolve_src(args.file.as_deref(), args.example.as_deref()).map_err(user)
}
fn cargo_build(out: &Path, release: bool) -> Result<(), Failure> {
if !out.join("Cargo.toml").is_file() {
return Err(user(format!(
"nothing to compile: {} has no Cargo.toml",
out.display()
)));
}
let mut cmd = Command::new("cargo");
cmd.arg("build");
if release {
cmd.arg("--release");
}
let status = cmd
.current_dir(out)
.status()
.map_err(|e| Failure::Internal(anyhow!("failed to run cargo: {e}")))?;
if status.success() {
println!("build: compiled {}", out.display());
Ok(())
} else {
Err(user("cargo build failed"))
}
}
fn failed(error: ProjectError, source: &Source) -> Failure {
match error {
ProjectError::Spec(diagnostics) => {
report::diagnostics(&source.name, &source.text, &diagnostics);
Failure::User
}
ProjectError::User(message) => user(message),
ProjectError::Internal(e) => Failure::Internal(e),
}
}
pub fn execute(args: &BuildArgs) -> Result<(), Failure> {
let source = resolve_source(args)?;
let checked = match pipeline::validate(&source.text, &args.answers.overrides()) {
Ok(c) => c,
Err(invalid) => {
report::invalid(&invalid, &source.name, &source.text);
return Err(Failure::User);
}
};
for note in &checked.notes {
eprintln!("{note}");
}
let board = match pipeline::board(&checked) {
Ok(board) => board,
Err(invalid) => {
report::invalid(&invalid, &source.name, &source.text);
return Err(Failure::User);
}
};
if let Some(path) = &args.write_spec {
project::write_spec(&checked, path).map_err(|e| failed(e, &source))?;
println!("build: wrote spec to {}", path.display());
}
let written = project::generate(
&source,
&checked,
&board,
args.runtime_path.clone(),
&args.out,
)
.map_err(|e| failed(e, &source))?;
println!(
"build: wrote '{}' to {}",
written.crate_name,
args.out.display()
);
if args.stage == Stage::Generate {
println!(" next: cd {} && cargo build", args.out.display());
return Ok(());
}
cargo_build(&args.out, args.release)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::answers::{ConsoleArg, RuntimeArg};
fn example_args(example: &str, out: &Path) -> BuildArgs {
BuildArgs {
example: Some(example.into()),
out: out.to_path_buf(),
stage: Stage::Generate,
..Default::default()
}
}
fn is_user<T: std::fmt::Debug>(r: Result<T, Failure>) -> bool {
matches!(r, Err(Failure::User))
}
#[test]
fn builds_the_helloworld_example() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("out");
execute(&example_args("01.Basics/helloworld", &out)).unwrap();
for f in [
"Cargo.toml",
"build.rs",
"src/lib.rs",
"src/bin/main.rs",
"src/app.rs",
"README.txt",
".espforge/manifest.json",
] {
assert!(out.join(f).is_file(), "missing {f}");
}
assert!(
std::fs::read_to_string(out.join("src/app.rs"))
.unwrap()
.contains("Hello World!")
);
assert!(
std::fs::read_to_string(out.join("Cargo.toml"))
.unwrap()
.contains("name = \"hello_world\"")
);
}
#[test]
fn builds_the_blink_example_with_a_led_component() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("out");
execute(&example_args("01.Basics/blink", &out)).unwrap();
let generated = std::fs::read_to_string(out.join("src/generated.rs")).unwrap();
assert!(
generated.contains("pub red_led: espforge_runtime::Led,"),
"{generated}"
);
assert!(generated.contains("GPIO18"), "{generated}");
let diagram = std::fs::read_to_string(out.join("diagram.json")).unwrap();
assert!(diagram.contains("esp:18"), "{diagram}");
assert!(
std::fs::read_to_string(out.join("src/bin/main.rs"))
.unwrap()
.contains("Components::new(&mut registry)")
);
}
#[test]
fn rebuilding_is_fine_but_edited_owned_files_are_refused() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("out");
let args = example_args("01.Basics/helloworld", &out);
execute(&args).unwrap();
execute(&args).unwrap();
std::fs::write(out.join("src/lib.rs"), "// mine").unwrap();
assert!(is_user(execute(&args)));
}
#[test]
fn flags_override_the_file_in_the_output() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("out");
let mut args = example_args("01.Basics/helloworld", &out);
args.answers.chip = Some("esp32s3".into());
args.answers.runtime = Some(RuntimeArg::Embassy);
args.answers.console = Some(ConsoleArg::JtagSerial);
execute(&args).unwrap();
let cargo = std::fs::read_to_string(out.join("Cargo.toml")).unwrap();
assert!(
cargo.contains("\"esp32s3\"")
&& cargo.contains("\"jtag-serial\"")
&& cargo.contains("embassy")
);
}
#[test]
fn option_errors_from_flags_are_user_errors() {
let dir = tempfile::tempdir().unwrap();
let mut args = example_args("01.Basics/helloworld", &dir.path().join("out"));
args.answers.options = vec!["wifi=true".into()]; assert!(is_user(execute(&args)));
args.answers.options = vec!["nope=1".into()];
assert!(is_user(execute(&args)));
}
#[test]
fn unsupported_sections_are_reported_not_silently_dropped() {
let dir = tempfile::tempdir().unwrap();
let src = "# espforge\n <project name=\"t\" />\n <chip type=\"esp32c3\" />\n <runtime type=\"blocking\" />\n# peripherals\n <pwm id=\"pwm0\" />\n# components\n# devices\n# services\n";
let f = dir.path().join("pwm.espf");
std::fs::write(&f, src).unwrap();
let args = BuildArgs {
file: Some(f),
out: dir.path().join("out"),
stage: Stage::Generate,
..Default::default()
};
assert!(is_user(execute(&args)));
}
#[test]
fn builds_the_button_example_with_button_and_led_components() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("out");
execute(&example_args("02.Digital/button", &out)).unwrap();
let generated = std::fs::read_to_string(out.join("src/generated.rs")).unwrap();
assert!(
generated.contains("pub red_led: espforge_runtime::Led,"),
"{generated}"
);
assert!(
generated.contains("pub button: espforge_runtime::Button,"),
"{generated}"
);
assert!(generated.contains("GPIO9"), "{generated}");
let diagram = std::fs::read_to_string(out.join("diagram.json")).unwrap();
assert!(diagram.contains("esp:9"), "{diagram}");
}
#[test]
fn source_must_be_exactly_one_of_file_or_example() {
let none = BuildArgs {
stage: Stage::Generate,
..Default::default()
};
assert!(is_user(execute(&none)));
let both = BuildArgs {
file: Some("x.espf".into()),
example: Some("01.Basics/helloworld".into()),
stage: Stage::Generate,
..Default::default()
};
assert!(is_user(execute(&both)));
let missing = BuildArgs {
example: Some("no/such".into()),
stage: Stage::Generate,
..Default::default()
};
assert!(is_user(execute(&missing)));
}
#[test]
fn syntax_errors_in_a_file_are_user_errors() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("bad.espf");
std::fs::write(&f, "# espforge\n <gpio id=\"a\" pin=\"4 />\n").unwrap();
let args = BuildArgs {
file: Some(f),
out: dir.path().join("out"),
stage: Stage::Generate,
..Default::default()
};
assert!(is_user(execute(&args)));
}
#[test]
fn builds_a_file_and_finds_app_logic_in_either_layout() {
let dir = tempfile::tempdir().unwrap();
let spec_dir = dir.path().join("proj");
std::fs::create_dir_all(&spec_dir).unwrap();
let hello = espforge_examples::find("01.Basics/helloworld").unwrap();
std::fs::write(spec_dir.join("hello.espf"), &hello.content).unwrap();
let out = dir.path().join("out");
let args = BuildArgs {
file: Some(spec_dir.join("hello.espf")),
out: out.clone(),
stage: Stage::Generate,
..Default::default()
};
assert!(is_user(execute(&args)));
std::fs::create_dir_all(spec_dir.join("app/rust")).unwrap();
std::fs::write(spec_dir.join("app/rust/app.rs"), "// example layout").unwrap();
execute(&args).unwrap();
assert_eq!(
std::fs::read_to_string(out.join("src/app.rs")).unwrap(),
"// example layout"
);
std::fs::create_dir_all(spec_dir.join("src")).unwrap();
std::fs::write(spec_dir.join("src/app.rs"), "// preferred").unwrap();
execute(&args).unwrap();
assert_eq!(
std::fs::read_to_string(out.join("src/app.rs")).unwrap(),
"// preferred"
);
}
#[test]
fn write_spec_produces_a_loadable_effective_spec() {
let dir = tempfile::tempdir().unwrap();
let spec_path = dir.path().join("effective.espf");
let mut args = example_args("01.Basics/helloworld", &dir.path().join("out"));
args.write_spec = Some(spec_path.clone());
args.answers.chip = Some("esp32c6".into());
execute(&args).unwrap();
let text = std::fs::read_to_string(&spec_path).unwrap();
assert!(text.contains("<chip type=\"esp32c6\" />"), "{text}");
assert!(espforge_core::frontend::load(&text).is_ok(), "{text}");
}
#[test]
fn runtime_path_is_validated_and_used() {
let dir = tempfile::tempdir().unwrap();
let rt = dir.path().join("rt");
std::fs::create_dir_all(&rt).unwrap();
let out = dir.path().join("out");
let mut args = example_args("01.Basics/helloworld", &out);
args.runtime_path = Some(rt.clone());
execute(&args).unwrap();
let cargo = std::fs::read_to_string(out.join("Cargo.toml")).unwrap();
assert!(
cargo.contains(&rt.to_string_lossy().replace('\\', "/")),
"{cargo}"
);
args.runtime_path = Some(dir.path().join("not-a-dir"));
assert!(is_user(execute(&args)));
}
#[test]
fn cargo_build_needs_a_generated_project() {
let dir = tempfile::tempdir().unwrap();
assert!(is_user(cargo_build(dir.path(), false)));
}
#[test]
fn an_unsupported_overlay_placeholder_is_a_user_error() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("t.espf");
std::fs::write(
&f,
"# espforge\n <project name=\"t\" />\n <chip type=\"esp32c3\" />\n <runtime type=\"blocking\" />\n# peripherals\n# components\n# devices\n# services\n",
)
.unwrap();
std::fs::write(dir.path().join("diagram.json"), "{ \"x\": \"{{ nope }}\" }").unwrap();
let args = BuildArgs {
file: Some(f),
out: dir.path().join("out"),
stage: Stage::Generate,
..Default::default()
};
assert!(is_user(execute(&args)));
}
}