use std::io;
use std::path::Path;
use anyhow::Context;
use cliclack::{Input, Select, confirm, intro, log, outro, outro_cancel};
use espforge_core::spec::{chip_value, set_chip};
use espforge_examples::{Category, Example};
use super::build::{self, BuildArgs, Failure, Stage};
const EXAMPLE_FILE: &str = "example.espf.txt";
pub fn run() {
match run_ui() {
Ok(()) => {}
Err(err) if err.kind() == io::ErrorKind::Interrupted => {
let _ = outro_cancel("Cancelled.");
std::process::exit(130);
}
Err(err) => {
eprintln!("⚠️ Could not run the example browser: {err}");
std::process::exit(2);
}
}
}
fn run_ui() -> io::Result<()> {
intro("espforge examples")?;
let categories = espforge_examples::categories();
if categories.is_empty() {
let _ = outro_cancel("No examples are embedded in this build.");
return Ok(());
}
let entries: Vec<(usize, String, String)> = categories
.iter()
.enumerate()
.map(|(i, category)| (i, category.name.clone(), category_hint(category)))
.collect();
let category_index = Select::<usize>::new("Select a category")
.items(&entries)
.interact()?;
let category = &categories[category_index];
let entries: Vec<(usize, String, String)> = category
.examples
.iter()
.enumerate()
.map(|(i, example)| (i, example.name.clone(), example_hint(example)))
.collect();
let example_index = Select::<usize>::new(format!("Select an example in {}", category.name))
.items(&entries)
.interact()?;
let example = &category.examples[example_index];
let dest: String = Input::new(format!(
"Where should '{}/{}' be copied to?",
category.name, example.name
))
.default_input(&example.name)
.interact()?;
let written = match espforge_examples::materialize(example, Path::new(&dest)) {
Ok(written) => written,
Err(err) => {
let _ = outro_cancel(format!("Could not copy the example: {err}"));
std::process::exit(2);
}
};
log::success(format!(
"Copied {}/{} to {} ({} files)",
category.name,
example.name,
dest,
written.len()
))?;
let espf_path = Path::new(&dest).join(EXAMPLE_FILE);
choose_chip(&espf_path)?;
let generate = confirm("Generate the project now?")
.initial_value(false)
.interact()?;
let mut generated = false;
if generate {
let out = Path::new(&dest).join("build");
match build::execute(&BuildArgs {
file: Some(espf_path.clone()),
out,
stage: Stage::Generate,
..Default::default()
}) {
Ok(()) => generated = true,
Err(Failure::User) => {
let _ = log::warning("The project was not generated; fix the problems above.");
}
Err(Failure::Internal(e)) => {
let _ = log::error(format!("{e:#}"));
}
}
}
outro(format!("Example ready in {dest}"))?;
if generated {
println!(" next: cd {dest}/build && cargo build");
} else {
println!(" next: cd {dest} → edit the example → espforge build {dest}/{EXAMPLE_FILE}");
}
Ok(())
}
fn choose_chip(espf_path: &Path) -> io::Result<()> {
let text = std::fs::read_to_string(espf_path)?;
let Some((_, current)) = chip_value(&text) else {
return Ok(());
};
let chips = espforge_options::chips::offered_chips();
let entries: Vec<(usize, String, String)> = chips
.iter()
.enumerate()
.map(|(i, c)| {
let hint = if *c == current {
"this example's chip"
} else {
""
};
(i, c.to_string(), hint.to_string())
})
.collect();
let initial = chips.iter().position(|c| *c == current).unwrap_or(0);
let picked = Select::<usize>::new("Chip")
.items(&entries)
.initial_value(initial)
.interact()?;
let chip = chips[picked];
if chip != current {
if let Some(new_text) = set_chip(&text, chip) {
std::fs::write(espf_path, new_text)?;
log::warning(format!(
"Changed the chip to {chip}. Pin numbers in this example were written for {current}; check them for {chip}."
))?;
}
}
Ok(())
}
pub fn run_categories() -> anyhow::Result<()> {
let categories = espforge_examples::categories();
if categories.is_empty() {
anyhow::bail!("no examples are embedded in this build");
}
for category in categories {
println!("{}\t{}", category.name, category_hint(category));
}
Ok(())
}
pub fn run_list(category: Option<&str>) -> anyhow::Result<()> {
let lines = espforge_examples::list(category)
.map_err(|err| anyhow::anyhow!("{err}"))
.context("failed to list examples")?;
for line in lines {
println!("{line}");
}
Ok(())
}
pub fn run_get(example_path: &str, out: &Path) -> anyhow::Result<()> {
let example = espforge_examples::find(example_path).context(format!(
"no example at '{example_path}'; run `espforge examples list` to see what is available"
))?;
let written = espforge_examples::materialize(example, out)
.with_context(|| format!("failed to copy '{example_path}' into {}", out.display()))?;
for path in written {
println!("{}", path.display());
}
Ok(())
}
fn example_hint(example: &Example) -> String {
match &example.title {
Some(title) => title.clone(),
None => String::new(),
}
}
fn category_hint(category: &Category) -> String {
let noun = if category.examples.len() == 1 {
"example"
} else {
"examples"
};
format!("{} {noun}", category.examples.len())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn categories_listing_works_and_counts_match_the_catalog() {
assert!(run_categories().is_ok());
for c in espforge_examples::categories() {
assert!(
category_hint(c).starts_with(&c.examples.len().to_string()),
"{}",
c.name
);
}
}
#[test]
fn list_without_a_category_covers_every_example() {
let total: usize = espforge_examples::categories()
.iter()
.map(|c| c.examples.len())
.sum();
assert_eq!(espforge_examples::list(None).unwrap().len(), total);
assert!(run_list(None).is_ok());
assert!(run_list(Some("no-such-category")).is_err());
}
#[test]
fn the_materialized_example_builds_after_a_chip_change() {
let tmp = tempfile::tempdir().unwrap();
let ex = espforge_examples::find("01.Basics/helloworld").unwrap();
let dest = tmp.path().join("hw");
espforge_examples::materialize(ex, &dest).unwrap();
let espf = dest.join(EXAMPLE_FILE);
let text = std::fs::read_to_string(&espf).unwrap();
std::fs::write(&espf, set_chip(&text, "esp32c6").unwrap()).unwrap();
let out = dest.join("build");
build::execute(&BuildArgs {
file: Some(espf),
out: out.clone(),
stage: Stage::Generate,
..Default::default()
})
.unwrap();
let cargo = std::fs::read_to_string(out.join("Cargo.toml")).unwrap();
assert!(cargo.contains("\"esp32c6\""));
}
}