use std::path::{Path, PathBuf};
use std::process::Command;
use crate::dev::debounce::BuildGeneration;
#[derive(Debug)]
pub struct Snapshot {
pub generation: BuildGeneration,
pub digest: [u8; 32],
pub staging: PathBuf,
}
#[derive(Debug, thiserror::Error)]
pub enum BuildError {
#[error("dev build io at {path}: {source}")]
Io {
path: PathBuf,
source: std::io::Error,
},
#[error("gleam build failed ({status}) in {staging}:\n{stderr}")]
GleamBuild {
status: String,
staging: PathBuf,
stderr: String,
},
#[error("expected compiled bytecode at {path} after a successful gleam build")]
MissingBeam {
path: PathBuf,
},
}
pub fn snapshot_component(
component_dir: &Path,
staging_root: &Path,
generation: BuildGeneration,
) -> Result<Snapshot, BuildError> {
let staging = staging_root.join(format!("gen-{}", generation.0));
if staging.exists() {
std::fs::remove_dir_all(&staging).map_err(|source| BuildError::Io {
path: staging.clone(),
source,
})?;
}
std::fs::create_dir_all(&staging).map_err(|source| BuildError::Io {
path: staging.clone(),
source,
})?;
let mut files = vec![PathBuf::from("gleam.toml")];
collect_files(component_dir, Path::new("src"), &mut files)?;
files.sort();
let mut hasher = blake3::Hasher::new();
for relative in &files {
let from = component_dir.join(relative);
let to = staging.join(relative);
if let Some(parent) = to.parent() {
std::fs::create_dir_all(parent).map_err(|source| BuildError::Io {
path: parent.to_path_buf(),
source,
})?;
}
let content = std::fs::read(&from).map_err(|source| BuildError::Io {
path: from.clone(),
source,
})?;
std::fs::write(&to, &content).map_err(|source| BuildError::Io {
path: to.clone(),
source,
})?;
hasher.update(relative.to_string_lossy().as_bytes());
hasher.update(&[0]);
hasher.update(&content);
hasher.update(&[0]);
}
Ok(Snapshot {
generation,
digest: *hasher.finalize().as_bytes(),
staging,
})
}
fn collect_files(
component_dir: &Path,
subdir: &Path,
files: &mut Vec<PathBuf>,
) -> Result<(), BuildError> {
let absolute = component_dir.join(subdir);
let entries = std::fs::read_dir(&absolute).map_err(|source| BuildError::Io {
path: absolute.clone(),
source,
})?;
for entry in entries {
let entry = entry.map_err(|source| BuildError::Io {
path: absolute.clone(),
source,
})?;
let relative = subdir.join(entry.file_name());
let kind = entry.file_type().map_err(|source| BuildError::Io {
path: component_dir.join(&relative),
source,
})?;
if kind.is_dir() {
collect_files(component_dir, &relative, files)?;
} else if kind.is_file() {
files.push(relative);
}
}
Ok(())
}
pub fn build_candidate(
snapshot: &Snapshot,
gleam_name: &str,
) -> Result<(Vec<u8>, Vec<u8>), BuildError> {
let output = Command::new("gleam")
.arg("build")
.current_dir(&snapshot.staging)
.output()
.map_err(|source| BuildError::Io {
path: snapshot.staging.clone(),
source,
})?;
if !output.status.success() {
return Err(BuildError::GleamBuild {
status: output.status.to_string(),
staging: snapshot.staging.clone(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
});
}
let ebin = snapshot
.staging
.join("build/dev/erlang")
.join(gleam_name)
.join("ebin");
let component = read_beam(&ebin.join(format!("{gleam_name}.beam")))?;
let ffi = read_beam(&ebin.join(format!("{gleam_name}_ffi.beam")))?;
Ok((component, ffi))
}
fn read_beam(path: &Path) -> Result<Vec<u8>, BuildError> {
if !path.is_file() {
return Err(BuildError::MissingBeam {
path: path.to_path_buf(),
});
}
std::fs::read(path).map_err(|source| BuildError::Io {
path: path.to_path_buf(),
source,
})
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::snapshot_component;
use crate::dev::debounce::BuildGeneration;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
static SEQUENCE: AtomicUsize = AtomicUsize::new(0);
struct Scratch(PathBuf);
impl Scratch {
fn new(label: &str) -> Self {
let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"frame-dev-build-{label}-{}-{sequence}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("scratch dir");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn write(root: &Path, relative: &str, content: &str) {
let path = root.join(relative);
std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
std::fs::write(path, content).expect("write");
}
fn component_fixture(root: &Path) {
write(root, "gleam.toml", "name = \"demo\"\n");
write(root, "src/demo.gleam", "pub fn main() { 1 }\n");
write(root, "src/nested/util.gleam", "pub fn u() { 2 }\n");
write(root, "build/dev/erlang/demo/ebin/demo.beam", "OLDBYTES");
}
#[test]
fn identical_trees_digest_identically_and_outputs_are_excluded() {
let a = Scratch::new("tree-a");
let b = Scratch::new("tree-b");
component_fixture(a.path());
component_fixture(b.path());
write(
b.path(),
"build/dev/erlang/demo/ebin/demo.beam",
"DIFFERENT",
);
let staging = Scratch::new("staging-identical");
let snap_a =
snapshot_component(a.path(), staging.path(), BuildGeneration(1)).expect("snapshot a");
let snap_b =
snapshot_component(b.path(), staging.path(), BuildGeneration(2)).expect("snapshot b");
assert_eq!(snap_a.digest, snap_b.digest);
assert_ne!(snap_a.staging, snap_b.staging, "generations stage apart");
}
#[test]
fn changed_bytes_change_the_digest_and_staging_is_fresh() {
let root = Scratch::new("tree-changed");
component_fixture(root.path());
let staging = Scratch::new("staging-changed");
let first =
snapshot_component(root.path(), staging.path(), BuildGeneration(1)).expect("snapshot");
write(root.path(), "src/demo.gleam", "pub fn main() { 99 }\n");
let second = snapshot_component(root.path(), staging.path(), BuildGeneration(1))
.expect("re-snapshot same generation");
assert_ne!(first.digest, second.digest);
let staged =
std::fs::read_to_string(second.staging.join("src/demo.gleam")).expect("staged source");
assert!(staged.contains("99"), "staging holds the fresh copy");
}
}