1use std::path::{Path, PathBuf};
9use std::process::Command;
10
11use crate::dev::debounce::BuildGeneration;
12
13#[derive(Debug)]
15pub struct Snapshot {
16 pub generation: BuildGeneration,
18 pub digest: [u8; 32],
22 pub staging: PathBuf,
24}
25
26#[derive(Debug, thiserror::Error)]
30pub enum BuildError {
31 #[error("dev build io at {path}: {source}")]
33 Io {
34 path: PathBuf,
36 source: std::io::Error,
38 },
39 #[error("gleam build failed ({status}) in {staging}:\n{stderr}")]
41 GleamBuild {
42 status: String,
44 staging: PathBuf,
46 stderr: String,
48 },
49 #[error("expected compiled bytecode at {path} after a successful gleam build")]
51 MissingBeam {
52 path: PathBuf,
54 },
55}
56
57pub fn snapshot_component(
66 component_dir: &Path,
67 staging_root: &Path,
68 generation: BuildGeneration,
69) -> Result<Snapshot, BuildError> {
70 let staging = staging_root.join(format!("gen-{}", generation.0));
71 if staging.exists() {
72 std::fs::remove_dir_all(&staging).map_err(|source| BuildError::Io {
73 path: staging.clone(),
74 source,
75 })?;
76 }
77 std::fs::create_dir_all(&staging).map_err(|source| BuildError::Io {
78 path: staging.clone(),
79 source,
80 })?;
81
82 let mut files = vec![PathBuf::from("gleam.toml")];
83 collect_files(component_dir, Path::new("src"), &mut files)?;
84 files.sort();
85
86 let mut hasher = blake3::Hasher::new();
87 for relative in &files {
88 let from = component_dir.join(relative);
89 let to = staging.join(relative);
90 if let Some(parent) = to.parent() {
91 std::fs::create_dir_all(parent).map_err(|source| BuildError::Io {
92 path: parent.to_path_buf(),
93 source,
94 })?;
95 }
96 let content = std::fs::read(&from).map_err(|source| BuildError::Io {
97 path: from.clone(),
98 source,
99 })?;
100 std::fs::write(&to, &content).map_err(|source| BuildError::Io {
101 path: to.clone(),
102 source,
103 })?;
104 hasher.update(relative.to_string_lossy().as_bytes());
105 hasher.update(&[0]);
106 hasher.update(&content);
107 hasher.update(&[0]);
108 }
109
110 Ok(Snapshot {
111 generation,
112 digest: *hasher.finalize().as_bytes(),
113 staging,
114 })
115}
116
117fn collect_files(
120 component_dir: &Path,
121 subdir: &Path,
122 files: &mut Vec<PathBuf>,
123) -> Result<(), BuildError> {
124 let absolute = component_dir.join(subdir);
125 let entries = std::fs::read_dir(&absolute).map_err(|source| BuildError::Io {
126 path: absolute.clone(),
127 source,
128 })?;
129 for entry in entries {
130 let entry = entry.map_err(|source| BuildError::Io {
131 path: absolute.clone(),
132 source,
133 })?;
134 let relative = subdir.join(entry.file_name());
135 let kind = entry.file_type().map_err(|source| BuildError::Io {
136 path: component_dir.join(&relative),
137 source,
138 })?;
139 if kind.is_dir() {
140 collect_files(component_dir, &relative, files)?;
141 } else if kind.is_file() {
142 files.push(relative);
143 }
144 }
145 Ok(())
146}
147
148pub fn build_candidate(
157 snapshot: &Snapshot,
158 gleam_name: &str,
159) -> Result<(Vec<u8>, Vec<u8>), BuildError> {
160 let output = Command::new("gleam")
161 .arg("build")
162 .current_dir(&snapshot.staging)
163 .output()
164 .map_err(|source| BuildError::Io {
165 path: snapshot.staging.clone(),
166 source,
167 })?;
168 if !output.status.success() {
169 return Err(BuildError::GleamBuild {
170 status: output.status.to_string(),
171 staging: snapshot.staging.clone(),
172 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
173 });
174 }
175 let ebin = snapshot
176 .staging
177 .join("build/dev/erlang")
178 .join(gleam_name)
179 .join("ebin");
180 let component = read_beam(&ebin.join(format!("{gleam_name}.beam")))?;
181 let ffi = read_beam(&ebin.join(format!("{gleam_name}_ffi.beam")))?;
182 Ok((component, ffi))
183}
184
185fn read_beam(path: &Path) -> Result<Vec<u8>, BuildError> {
186 if !path.is_file() {
187 return Err(BuildError::MissingBeam {
188 path: path.to_path_buf(),
189 });
190 }
191 std::fs::read(path).map_err(|source| BuildError::Io {
192 path: path.to_path_buf(),
193 source,
194 })
195}
196
197#[cfg(test)]
198mod tests {
199 #![allow(clippy::expect_used)]
200
201 use super::snapshot_component;
202 use crate::dev::debounce::BuildGeneration;
203 use std::path::{Path, PathBuf};
204 use std::sync::atomic::{AtomicUsize, Ordering};
205
206 static SEQUENCE: AtomicUsize = AtomicUsize::new(0);
207
208 struct Scratch(PathBuf);
212
213 impl Scratch {
214 fn new(label: &str) -> Self {
215 let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed);
216 let path = std::env::temp_dir().join(format!(
217 "frame-dev-build-{label}-{}-{sequence}",
218 std::process::id()
219 ));
220 let _ = std::fs::remove_dir_all(&path);
221 std::fs::create_dir_all(&path).expect("scratch dir");
222 Self(path)
223 }
224
225 fn path(&self) -> &Path {
226 &self.0
227 }
228 }
229
230 impl Drop for Scratch {
231 fn drop(&mut self) {
232 let _ = std::fs::remove_dir_all(&self.0);
233 }
234 }
235
236 fn write(root: &Path, relative: &str, content: &str) {
237 let path = root.join(relative);
238 std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
239 std::fs::write(path, content).expect("write");
240 }
241
242 fn component_fixture(root: &Path) {
243 write(root, "gleam.toml", "name = \"demo\"\n");
244 write(root, "src/demo.gleam", "pub fn main() { 1 }\n");
245 write(root, "src/nested/util.gleam", "pub fn u() { 2 }\n");
246 write(root, "build/dev/erlang/demo/ebin/demo.beam", "OLDBYTES");
248 }
249
250 #[test]
254 fn identical_trees_digest_identically_and_outputs_are_excluded() {
255 let a = Scratch::new("tree-a");
256 let b = Scratch::new("tree-b");
257 component_fixture(a.path());
258 component_fixture(b.path());
259 write(
261 b.path(),
262 "build/dev/erlang/demo/ebin/demo.beam",
263 "DIFFERENT",
264 );
265
266 let staging = Scratch::new("staging-identical");
267 let snap_a =
268 snapshot_component(a.path(), staging.path(), BuildGeneration(1)).expect("snapshot a");
269 let snap_b =
270 snapshot_component(b.path(), staging.path(), BuildGeneration(2)).expect("snapshot b");
271 assert_eq!(snap_a.digest, snap_b.digest);
272 assert_ne!(snap_a.staging, snap_b.staging, "generations stage apart");
273 }
274
275 #[test]
278 fn changed_bytes_change_the_digest_and_staging_is_fresh() {
279 let root = Scratch::new("tree-changed");
280 component_fixture(root.path());
281 let staging = Scratch::new("staging-changed");
282
283 let first =
284 snapshot_component(root.path(), staging.path(), BuildGeneration(1)).expect("snapshot");
285 write(root.path(), "src/demo.gleam", "pub fn main() { 99 }\n");
286 let second = snapshot_component(root.path(), staging.path(), BuildGeneration(1))
287 .expect("re-snapshot same generation");
288 assert_ne!(first.digest, second.digest);
289 let staged =
290 std::fs::read_to_string(second.staging.join("src/demo.gleam")).expect("staged source");
291 assert!(staged.contains("99"), "staging holds the fresh copy");
292 }
293}