Skip to main content

frame_cli/dev/
build.rs

1//! Candidate builds in generation-specific staging (F-7b R3): each build
2//! generation gets its own staging copy of the component inputs, so
3//! compiler partials can never overwrite last-good bytes, and the content
4//! snapshot is identified by a BLAKE3 digest computed over the copied
5//! bytes in deterministic order (constraint 8 — the digest, not timing,
6//! identifies a build).
7
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11use crate::dev::debounce::BuildGeneration;
12
13/// One staged content snapshot, ready to build.
14#[derive(Debug)]
15pub struct Snapshot {
16    /// The build generation this snapshot belongs to.
17    pub generation: BuildGeneration,
18    /// BLAKE3 over every copied file's relative path and content, in
19    /// sorted path order — byte-identical trees digest identically no
20    /// matter what order the OS listed them in.
21    pub digest: [u8; 32],
22    /// The generation-specific staging directory holding the copy.
23    pub staging: PathBuf,
24}
25
26/// Typed failures of the snapshot/build pipeline. Compiler diagnostics
27/// ride [`Self::GleamBuild`] verbatim (R4: captured, never summarized
28/// away).
29#[derive(Debug, thiserror::Error)]
30pub enum BuildError {
31    /// A filesystem step failed, path named.
32    #[error("dev build io at {path}: {source}")]
33    Io {
34        /// The path the operation touched.
35        path: PathBuf,
36        /// The exact io failure.
37        source: std::io::Error,
38    },
39    /// `gleam build` exited nonzero; stderr attached verbatim.
40    #[error("gleam build failed ({status}) in {staging}:\n{stderr}")]
41    GleamBuild {
42        /// The exit status.
43        status: String,
44        /// The staging directory that was built.
45        staging: PathBuf,
46        /// Captured compiler diagnostics, verbatim.
47        stderr: String,
48    },
49    /// A compiled BEAM file the landed ebin shape promises was absent.
50    #[error("expected compiled bytecode at {path} after a successful gleam build")]
51    MissingBeam {
52        /// The absent path.
53        path: PathBuf,
54    },
55}
56
57/// Copies `component/gleam.toml` and `component/src/**` into the
58/// generation's staging directory and digests the copied content. A
59/// leftover directory from a superseded attempt at the same generation is
60/// removed first — staging is always a fresh, complete copy.
61///
62/// # Errors
63///
64/// [`BuildError::Io`] with the failing path.
65pub 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
117/// Recursively collects file paths under `component_dir/subdir`, relative
118/// to `component_dir`. Order is irrelevant — the caller sorts.
119fn 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
148/// Runs `gleam build` in the snapshot's staging directory and reads both
149/// compiled BEAM files from the landed ebin shape.
150///
151/// # Errors
152///
153/// [`BuildError::GleamBuild`] with verbatim diagnostics on a compile
154/// failure; [`BuildError::MissingBeam`]/[`BuildError::Io`] when a
155/// promised artifact is absent or unreadable.
156pub 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    /// Uniquely named scratch directory under the system temp root,
209    /// removed on drop (the tests/support idiom, inlined for a unit
210    /// test's reach).
211    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        // Build outputs and caches exist in real trees and are NOT inputs.
247        write(root, "build/dev/erlang/demo/ebin/demo.beam", "OLDBYTES");
248    }
249
250    /// Identical trees digest identically; the digest covers exactly the
251    /// declared inputs (manifest + src/**), so build outputs never leak
252    /// into the identity.
253    #[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        // Different build outputs must not change the digest.
260        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    /// One changed input byte changes the identity; a re-snapshot of the
276    /// same generation replaces a stale partial completely.
277    #[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}