frame-cli 0.4.0

CLI for Frame — six intention-verbs over one application: frame new scaffolds it, frame run serves it, frame dev hot-reloads it against the running node, frame test proves it (real browser included), frame check verifies it statically, frame doctor walks the prerequisites
Documentation
//! Candidate builds in generation-specific staging (F-7b R3): each build
//! generation gets its own staging copy of the component inputs, so
//! compiler partials can never overwrite last-good bytes, and the content
//! snapshot is identified by a BLAKE3 digest computed over the copied
//! bytes in deterministic order (constraint 8 — the digest, not timing,
//! identifies a build).

use std::path::{Path, PathBuf};
use std::process::Command;

use crate::dev::debounce::BuildGeneration;

/// One staged content snapshot, ready to build.
#[derive(Debug)]
pub struct Snapshot {
    /// The build generation this snapshot belongs to.
    pub generation: BuildGeneration,
    /// BLAKE3 over every copied file's relative path and content, in
    /// sorted path order — byte-identical trees digest identically no
    /// matter what order the OS listed them in.
    pub digest: [u8; 32],
    /// The generation-specific staging directory holding the copy.
    pub staging: PathBuf,
}

/// Typed failures of the snapshot/build pipeline. Compiler diagnostics
/// ride [`Self::GleamBuild`] verbatim (R4: captured, never summarized
/// away).
#[derive(Debug, thiserror::Error)]
pub enum BuildError {
    /// A filesystem step failed, path named.
    #[error("dev build io at {path}: {source}")]
    Io {
        /// The path the operation touched.
        path: PathBuf,
        /// The exact io failure.
        source: std::io::Error,
    },
    /// `gleam build` exited nonzero; stderr attached verbatim.
    #[error("gleam build failed ({status}) in {staging}:\n{stderr}")]
    GleamBuild {
        /// The exit status.
        status: String,
        /// The staging directory that was built.
        staging: PathBuf,
        /// Captured compiler diagnostics, verbatim.
        stderr: String,
    },
    /// A compiled BEAM file the landed ebin shape promises was absent.
    #[error("expected compiled bytecode at {path} after a successful gleam build")]
    MissingBeam {
        /// The absent path.
        path: PathBuf,
    },
}

/// Copies `component/gleam.toml` and `component/src/**` into the
/// generation's staging directory and digests the copied content. A
/// leftover directory from a superseded attempt at the same generation is
/// removed first — staging is always a fresh, complete copy.
///
/// # Errors
///
/// [`BuildError::Io`] with the failing path.
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,
    })
}

/// Recursively collects file paths under `component_dir/subdir`, relative
/// to `component_dir`. Order is irrelevant — the caller sorts.
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(())
}

/// Runs `gleam build` in the snapshot's staging directory and reads both
/// compiled BEAM files from the landed ebin shape.
///
/// # Errors
///
/// [`BuildError::GleamBuild`] with verbatim diagnostics on a compile
/// failure; [`BuildError::MissingBeam`]/[`BuildError::Io`] when a
/// promised artifact is absent or unreadable.
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);

    /// Uniquely named scratch directory under the system temp root,
    /// removed on drop (the tests/support idiom, inlined for a unit
    /// test's reach).
    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");
        // Build outputs and caches exist in real trees and are NOT inputs.
        write(root, "build/dev/erlang/demo/ebin/demo.beam", "OLDBYTES");
    }

    /// Identical trees digest identically; the digest covers exactly the
    /// declared inputs (manifest + src/**), so build outputs never leak
    /// into the identity.
    #[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());
        // Different build outputs must not change the digest.
        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");
    }

    /// One changed input byte changes the identity; a re-snapshot of the
    /// same generation replaces a stale partial completely.
    #[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");
    }
}