use anyhow::{Context, Result};
use espforge_emit::RuntimeSource;
use std::path::{Path, PathBuf};
pub struct Source {
pub name: String,
pub text: String,
pub dir: Option<PathBuf>,
pub example: Option<&'static espforge_examples::Example>,
}
pub fn from_file(file: &Path) -> Result<Source, String> {
let text = std::fs::read_to_string(file)
.map_err(|e| format!("cannot read '{}': {e}", file.display()))?;
let dir = file
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
Ok(Source {
name: file.to_string_lossy().into_owned(),
text,
dir: Some(dir),
example: None,
})
}
pub fn find_project(dir: &Path) -> Result<PathBuf, String> {
let entries =
std::fs::read_dir(dir).map_err(|e| format!("cannot read '{}': {e}", dir.display()))?;
let mut names: Vec<String> = entries
.filter_map(|e| e.ok())
.filter(|e| e.path().is_file())
.filter_map(|e| e.file_name().into_string().ok())
.filter(|n| n.ends_with(".espf") || n.ends_with(".espf.txt"))
.collect();
names.sort();
let at = |name: &str| {
if dir == Path::new(".") {
PathBuf::from(name)
} else {
dir.join(name)
}
};
match names.len() {
0 => Err(format!(
"no .espf file in '{}'; give a FILE or --example <category/name>",
dir.display()
)),
1 => Ok(at(&names[0])),
_ => Err(format!(
"several .espf files in '{}' ({}); give one explicitly",
dir.display(),
names.join(", ")
)),
}
}
pub fn resolve_source(file: Option<&Path>, example: Option<&str>) -> Result<Source, String> {
match (file, example) {
(Some(_), Some(_)) => Err("give either FILE or --example, not both".to_string()),
(Some(f), None) => from_file(f),
(None, None) => from_file(&find_project(Path::new("."))?),
(None, Some(path)) => {
let ex = espforge_examples::find(path).ok_or_else(|| {
format!(
"no example '{path}'; run `espforge examples list` to see what is available"
)
})?;
Ok(Source {
name: format!("embedded:{path}"),
text: ex.content.clone(),
dir: None,
example: Some(ex),
})
}
}
}
const OVERLAY_FILES: [&str; 4] = ["diagram.json", "wokwi.toml", "chip.json", "chip.wasm"];
fn collect_dir(dir: &Path, prefix: &str, out: &mut Vec<(String, Vec<u8>)>) -> Result<()> {
if !dir.is_dir() {
return Ok(());
}
let mut entries = std::fs::read_dir(dir)?.collect::<std::io::Result<Vec<_>>>()?;
entries.sort_by_key(|e| e.file_name());
for e in entries {
let rel = format!("{prefix}/{}", e.file_name().to_string_lossy());
let path = e.path();
if path.is_dir() {
collect_dir(&path, &rel, out)?;
} else {
out.push((rel, std::fs::read(&path)?));
}
}
Ok(())
}
pub fn overlay_files(src: &Source) -> Result<Vec<(String, Vec<u8>)>> {
if let Some(ex) = src.example {
return Ok(ex
.files
.iter()
.map(|f| (f.path.clone(), f.contents.to_vec()))
.collect());
}
let dir = src.dir.as_ref().context("a file source has a directory")?;
let mut out = Vec::new();
for name in OVERLAY_FILES {
let p = dir.join(name);
if p.is_file() {
out.push((
name.to_string(),
std::fs::read(&p).with_context(|| format!("cannot read {}", p.display()))?,
));
}
}
collect_dir(&dir.join("chip"), "chip", &mut out)?;
Ok(out)
}
pub fn find_app(dir: &Path) -> Option<PathBuf> {
let preferred = dir.join("src").join("app.rs");
if preferred.is_file() {
return Some(preferred);
}
let example_layout = dir.join("app").join("rust").join("app.rs");
example_layout.is_file().then_some(example_layout)
}
pub fn app_logic(src: &Source) -> Result<Vec<u8>, String> {
if let Some(ex) = src.example {
return ex
.files
.iter()
.find(|f| f.path == "app/rust/app.rs")
.map(|f| f.contents.to_vec())
.ok_or_else(|| "this example has no app/rust/app.rs".to_string());
}
let dir = src
.dir
.as_ref()
.ok_or_else(|| "this source has no directory to read app logic from".to_string())?;
let found = find_app(dir).ok_or_else(|| {
format!(
"missing {} (or {}): your app logic (setup/forever)",
dir.join("src").join("app.rs").display(),
dir.join("app").join("rust").join("app.rs").display()
)
})?;
std::fs::read(&found).map_err(|e| e.to_string())
}
pub fn runtime_source(runtime_path: Option<PathBuf>) -> Result<RuntimeSource, String> {
let path =
runtime_path.or_else(|| std::env::var_os("ESPFORGE_RUNTIME_PATH").map(PathBuf::from));
match path {
Some(p) => {
let abs = std::path::absolute(&p).map_err(|e| e.to_string())?;
if !abs.is_dir() {
return Err(format!("runtime path '{}' is not a directory", p.display()));
}
Ok(RuntimeSource::Path(abs.to_string_lossy().into_owned()))
}
None => Ok(RuntimeSource::Published),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn find_project_wants_exactly_one() {
let dir = tempfile::tempdir().unwrap();
assert!(find_project(dir.path()).is_err());
std::fs::write(dir.path().join("a.espf"), "").unwrap();
std::fs::write(dir.path().join("notes.txt"), "").unwrap();
assert_eq!(find_project(dir.path()).unwrap(), dir.path().join("a.espf"));
std::fs::write(dir.path().join("b.espf.txt"), "").unwrap();
let err = find_project(dir.path()).unwrap_err();
assert!(
err.contains("a.espf") && err.contains("b.espf.txt"),
"{err}"
);
}
#[test]
fn file_and_example_together_are_rejected() {
assert!(resolve_source(Some(Path::new("x.espf")), Some("01.Basics/helloworld")).is_err());
assert!(resolve_source(None, Some("no/such")).is_err());
}
#[test]
fn find_app_prefers_src_app_rs() {
let dir = tempfile::tempdir().unwrap();
assert!(find_app(dir.path()).is_none());
std::fs::create_dir_all(dir.path().join("app/rust")).unwrap();
std::fs::write(dir.path().join("app/rust/app.rs"), "").unwrap();
assert!(find_app(dir.path()).unwrap().ends_with("app/rust/app.rs"));
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::write(dir.path().join("src/app.rs"), "").unwrap();
assert!(find_app(dir.path()).unwrap().ends_with("src/app.rs"));
}
#[test]
fn materialized_example_folders_overlay_their_wokwi_files() {
let dir = tempfile::tempdir().unwrap();
let ex = espforge_examples::find("04.Communication/i2c_scanner").unwrap();
let folder = dir.path().join("ex");
espforge_examples::materialize(ex, &folder).unwrap();
let src = Source {
name: "x".into(),
text: String::new(),
dir: Some(folder),
example: None,
};
let files = overlay_files(&src).unwrap();
let names: Vec<&str> = files.iter().map(|(n, _)| n.as_str()).collect();
assert!(
names.contains(&"diagram.json")
&& names.contains(&"chip.json")
&& names.contains(&"chip.wasm"),
"{names:?}"
);
assert!(names.contains(&"chip/chip.zig"), "{names:?}");
assert!(!names.contains(&"example.espf.txt"));
}
}