frame-cli 0.3.0

CLI for Frame — five intention-verbs over one application: frame new scaffolds it, frame run serves it, frame test proves it (real browser included), frame check verifies it statically, frame doctor walks the prerequisites
Documentation
//! Deterministic, atomic generation of one Frame application tree.

use std::fs;
use std::io;
use std::path::{Path, PathBuf};

use thiserror::Error;

use super::names::validate_name;
use super::templates;

/// Inputs to one deterministic scaffold generation.
#[derive(Clone, Debug)]
pub struct NewOptions {
    /// Name used by the application and component.
    pub name: String,
    /// Parent directory beneath which the named project is created.
    pub target_parent: PathBuf,
}

/// Typed refusal or generation failure from `frame new`.
#[derive(Debug, Error)]
pub enum NewError {
    /// The name fails the Rust crate identifier rule.
    #[error(
        "name `{name}` is not a valid Rust crate identifier: {reason}; use a non-keyword matching [a-z][a-z0-9_]* (which also passes the Gleam project-name rule)"
    )]
    InvalidCrateName {
        /// Rejected input.
        name: String,
        /// Specific Rust identifier violation.
        reason: &'static str,
    },
    /// The name fails the Gleam project-name rule.
    #[error(
        "name `{name}` is not a valid Gleam project name: {reason}; use [a-z][a-z0-9_]* (and avoid Rust keywords to also pass the crate-identifier rule)"
    )]
    InvalidGleamName {
        /// Rejected input.
        name: String,
        /// Specific Gleam naming violation.
        reason: &'static str,
    },
    /// Both language naming rules reject the name.
    #[error(
        "name `{name}` fails both rules: Rust crate identifier ({crate_reason}) and Gleam project name ({gleam_reason}); use [a-z][a-z0-9_]* and avoid Rust keywords"
    )]
    InvalidBothNames {
        /// Rejected input.
        name: String,
        /// Specific Rust identifier violation.
        crate_reason: &'static str,
        /// Specific Gleam naming violation.
        gleam_reason: &'static str,
    },
    /// The target is protected from replacement.
    #[error(
        "target directory `{path}` already exists; frame new never overwrites and has no --force"
    )]
    TargetExists {
        /// Existing path protected from overwrite.
        path: PathBuf,
    },
    /// A named filesystem generation operation failed.
    #[error("generation failed while {operation} `{path}`: {source}")]
    Generation {
        /// Operation that failed.
        operation: &'static str,
        /// File or directory involved.
        path: PathBuf,
        /// Underlying filesystem error.
        #[source]
        source: io::Error,
    },
    /// Generation failed and atomic cleanup also failed.
    #[error(
        "generation failed while {operation} `{path}`: {source}; removing partial target `{target}` also failed: {cleanup}"
    )]
    GenerationAndCleanup {
        /// Original operation that failed.
        operation: &'static str,
        /// Original file or directory involved.
        path: PathBuf,
        /// Original filesystem error.
        source: io::Error,
        /// Partial target whose cleanup failed.
        target: PathBuf,
        /// Cleanup filesystem error.
        cleanup: io::Error,
    },
}

/// Atomically generates one application tree.
///
/// # Errors
///
/// Refuses invalid names, invalid roots, existing targets, and any named I/O failure.
pub fn generate(options: &NewOptions) -> Result<PathBuf, NewError> {
    generate_with_hook(options, |_| Ok(()))
}

fn generate_with_hook(
    options: &NewOptions,
    mut before_write: impl FnMut(&Path) -> io::Result<()>,
) -> Result<PathBuf, NewError> {
    validate_name(&options.name)?;
    let target = options.target_parent.join(&options.name);
    if target.exists() {
        return Err(NewError::TargetExists { path: target });
    }
    fs::create_dir(&target).map_err(|source| NewError::Generation {
        operation: "creating target directory",
        path: target.clone(),
        source,
    })?;

    let result = write_tree(&target, &options.name, &mut before_write);
    if let Err(NewError::Generation {
        operation,
        path,
        source,
    }) = result
    {
        return match fs::remove_dir_all(&target) {
            Ok(()) => Err(NewError::Generation {
                operation,
                path,
                source,
            }),
            Err(cleanup) => Err(NewError::GenerationAndCleanup {
                operation,
                path,
                source,
                target,
                cleanup,
            }),
        };
    }
    result?;
    Ok(target)
}

fn write_tree(
    target: &Path,
    name: &str,
    before_write: &mut impl FnMut(&Path) -> io::Result<()>,
) -> Result<(), NewError> {
    for (relative, contents) in rendered_files(name) {
        write_file(target, &relative, contents.as_bytes(), before_write)?;
    }
    // The vendored SDK artifact is written byte-exact, outside render():
    // templating a third-party build output would corrupt it. It is fully
    // self-contained (WebAssembly inlined) — one file is the whole vendor
    // story.
    write_file(
        target,
        "page/dist/vendor/liminal.js",
        templates::PAGE_VENDOR_LIMINAL_JS.as_bytes(),
        before_write,
    )?;
    Ok(())
}

fn rendered_files(name: &str) -> Vec<(String, String)> {
    let mut files = host_and_component_files(name);
    files.extend(page_files(name));
    files
}

fn host_and_component_files(name: &str) -> Vec<(String, String)> {
    use templates::render;
    let gleam_name = name;
    let component_source = format!("component/src/{gleam_name}.gleam");
    let component_ffi = format!("component/src/{gleam_name}_ffi.erl");
    let component_test = format!("component/test/{gleam_name}_test.gleam");
    let files = [
        (
            "Cargo.toml",
            render(templates::ROOT_CARGO, name, gleam_name),
        ),
        (
            ".gitignore",
            render(templates::ROOT_GITIGNORE, name, gleam_name),
        ),
        (
            "frame.toml",
            render(templates::FRAME_TOML, name, gleam_name),
        ),
        ("README.md", render(templates::README, name, gleam_name)),
        (
            "host/Cargo.toml",
            render(templates::HOST_CARGO, name, gleam_name),
        ),
        (
            "host/build.rs",
            render(templates::BUILD_RS, name, gleam_name),
        ),
        (
            "host/src/lib.rs",
            render(templates::HOST_LIB, name, gleam_name),
        ),
        (
            "host/src/main.rs",
            render(templates::HOST_MAIN, name, gleam_name),
        ),
        (
            "host/tests/e2e.rs",
            render(templates::HOST_E2E, name, gleam_name),
        ),
        (
            "component/gleam.toml",
            render(templates::GLEAM_TOML, name, gleam_name),
        ),
        (
            component_source.as_str(),
            render(templates::COMPONENT, name, gleam_name),
        ),
        (
            component_ffi.as_str(),
            render(templates::COMPONENT_FFI, name, gleam_name),
        ),
        (
            component_test.as_str(),
            render(templates::COMPONENT_TEST, name, gleam_name),
        ),
    ];
    files
        .into_iter()
        .map(|(relative, contents)| (relative.to_string(), contents))
        .collect()
}

fn page_files(name: &str) -> Vec<(String, String)> {
    use templates::render;
    let gleam_name = name;
    // Write order is load-bearing for the page: every `page/src` source is
    // written BEFORE its pre-compiled `page/dist` module, so a fresh
    // scaffold's compiled page is never older than its sources and
    // `frame run`'s staleness comparison starts from "up to date".
    let files = [
        (
            "page/package.json",
            render(templates::PAGE_PACKAGE_JSON, name, gleam_name),
        ),
        (
            "page/tsconfig.json",
            render(templates::PAGE_TSCONFIG, name, gleam_name),
        ),
        (
            "page/src/config.ts",
            render(templates::PAGE_CONFIG_TS, name, gleam_name),
        ),
        (
            "page/src/app-status.ts",
            render(templates::PAGE_APP_STATUS_TS, name, gleam_name),
        ),
        (
            "page/src/connection.ts",
            render(templates::PAGE_CONNECTION_TS, name, gleam_name),
        ),
        (
            "page/src/main.ts",
            render(templates::PAGE_MAIN_TS, name, gleam_name),
        ),
        (
            "page/scripts/e2e.mjs",
            render(templates::PAGE_E2E_MJS, name, gleam_name),
        ),
        (
            "page/dist/index.html",
            render(templates::PAGE_INDEX_HTML, name, gleam_name),
        ),
        (
            "page/dist/styles.css",
            render(templates::PAGE_STYLES_CSS, name, gleam_name),
        ),
        // The pre-compiled page modules: byte-exact pinned-tsc output of the
        // page/src sources above (see templates.rs), rendered through the
        // same placeholder pass — tsc passes string content through
        // verbatim, so render-then-compile and compile-then-render agree
        // byte for byte. Emitting them completes the servable root at
        // scaffold time: the first `frame run` needs no npm.
        (
            "page/dist/config.js",
            render(templates::PAGE_DIST_CONFIG_JS, name, gleam_name),
        ),
        (
            "page/dist/app-status.js",
            render(templates::PAGE_DIST_APP_STATUS_JS, name, gleam_name),
        ),
        (
            "page/dist/connection.js",
            render(templates::PAGE_DIST_CONNECTION_JS, name, gleam_name),
        ),
        (
            "page/dist/main.js",
            render(templates::PAGE_DIST_MAIN_JS, name, gleam_name),
        ),
    ];
    files
        .into_iter()
        .map(|(relative, contents)| (relative.to_string(), contents))
        .collect()
}

fn write_file(
    target: &Path,
    relative: &str,
    contents: &[u8],
    before_write: &mut impl FnMut(&Path) -> io::Result<()>,
) -> Result<(), NewError> {
    let path = target.join(relative);
    before_write(&path).map_err(|source| NewError::Generation {
        operation: "preparing generated file",
        path: path.clone(),
        source,
    })?;
    let parent = path.parent().ok_or_else(|| NewError::Generation {
        operation: "finding parent for generated file",
        path: path.clone(),
        source: io::Error::other("generated path had no parent"),
    })?;
    fs::create_dir_all(parent).map_err(|source| NewError::Generation {
        operation: "creating generated subdirectory for",
        path: path.clone(),
        source,
    })?;
    fs::write(&path, contents).map_err(|source| NewError::Generation {
        operation: "writing generated file",
        path,
        source,
    })?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::{SystemTime, UNIX_EPOCH};

    fn temp(name: &str) -> Result<PathBuf, std::time::SystemTimeError> {
        let unique = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
        Ok(std::env::temp_dir().join(format!("frame-cli-{name}-{unique}")))
    }

    #[test]
    fn injected_mid_generation_failure_removes_partial_tree()
    -> Result<(), Box<dyn std::error::Error>> {
        let parent = temp("atomic")?;
        fs::create_dir_all(&parent)?;
        let options = NewOptions {
            name: "atomic_app".to_owned(),
            target_parent: parent.clone(),
        };
        let result = generate_with_hook(&options, |path| {
            if path.ends_with("host/build.rs") {
                Err(io::Error::new(
                    io::ErrorKind::PermissionDenied,
                    "injected unwritable subpath",
                ))
            } else {
                Ok(())
            }
        });
        assert!(matches!(result, Err(NewError::Generation { .. })));
        assert!(!parent.join("atomic_app").exists());
        fs::remove_dir_all(parent)?;
        Ok(())
    }
}