frame-cli 0.1.0

CLI for Frame — scaffold applications, manage components, run dev server
Documentation
//! Testable implementation of the `frame` command-line interface.

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

use clap::{Parser, Subcommand};
use thiserror::Error;

// Named project-Cargo.toml, not Cargo.toml: cargo package treats any
// subdirectory containing a file with that exact name as a nested package
// and silently excludes the whole directory from the publish tarball.
const ROOT_CARGO: &str = include_str!("../templates/project-Cargo.toml");
const HOST_CARGO: &str = include_str!("../templates/host-Cargo.toml");
const BUILD_RS: &str = include_str!("../templates/build.rs");
const HOST_LIB: &str = include_str!("../templates/host-lib.rs");
const HOST_MAIN: &str = include_str!("../templates/host-main.rs");
const HOST_E2E: &str = include_str!("../templates/host-e2e.rs");
const GLEAM_TOML: &str = include_str!("../templates/gleam.toml");
const COMPONENT: &str = include_str!("../templates/component.gleam");
const COMPONENT_FFI: &str = include_str!("../templates/component_ffi.erl");
const COMPONENT_TEST: &str = include_str!("../templates/component_test.gleam");
const README: &str = include_str!("../templates/README.md");

/// Parsed command line for the Frame operator tool.
#[derive(Debug, Parser)]
#[command(name = "frame", version, about = "Frame application tooling")]
pub struct Cli {
    /// Operation to perform.
    #[command(subcommand)]
    pub command: Command,
}

/// Supported Frame operations.
#[derive(Debug, Subcommand)]
pub enum Command {
    /// Generate a new Frame application without overwriting existing paths.
    New {
        /// Application and Gleam project name.
        name: String,
    },
}

/// 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,
    },
}

/// Executes a parsed CLI command.
///
/// # Errors
///
/// Returns a typed refusal or filesystem failure from scaffold generation.
pub fn execute(cli: Cli) -> Result<PathBuf, NewError> {
    match cli.command {
        Command::New { name } => {
            let options = NewOptions {
                name,
                target_parent: std::env::current_dir().map_err(|source| NewError::Generation {
                    operation: "reading the current directory for",
                    path: PathBuf::from("."),
                    source,
                })?,
            };
            generate(&options)
        }
    }
}

/// 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> {
    let gleam_name = name;
    let files = [
        ("Cargo.toml", render(ROOT_CARGO, name, gleam_name)),
        ("README.md", render(README, name, gleam_name)),
        ("host/Cargo.toml", render(HOST_CARGO, name, gleam_name)),
        ("host/build.rs", render(BUILD_RS, name, gleam_name)),
        ("host/src/lib.rs", render(HOST_LIB, name, gleam_name)),
        ("host/src/main.rs", render(HOST_MAIN, name, gleam_name)),
        ("host/tests/e2e.rs", render(HOST_E2E, name, gleam_name)),
        ("component/gleam.toml", render(GLEAM_TOML, name, gleam_name)),
        (
            &format!("component/src/{gleam_name}.gleam"),
            render(COMPONENT, name, gleam_name),
        ),
        (
            &format!("component/src/{gleam_name}_ffi.erl"),
            render(COMPONENT_FFI, name, gleam_name),
        ),
        (
            &format!("component/test/{gleam_name}_test.gleam"),
            render(COMPONENT_TEST, name, gleam_name),
        ),
    ];
    for (relative, contents) in files {
        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(())
}

fn render(template: &str, name: &str, gleam_name: &str) -> String {
    template
        .replace("{{NAME}}", name)
        .replace("{{GLEAM_NAME}}", gleam_name)
}

fn validate_name(name: &str) -> Result<(), NewError> {
    let crate_error = crate_name_error(name);
    let gleam_error = gleam_name_error(name);
    match (crate_error, gleam_error) {
        (None, None) => Ok(()),
        (Some(crate_reason), None) => Err(NewError::InvalidCrateName {
            name: name.to_owned(),
            reason: crate_reason,
        }),
        (None, Some(gleam_reason)) => Err(NewError::InvalidGleamName {
            name: name.to_owned(),
            reason: gleam_reason,
        }),
        (Some(crate_reason), Some(gleam_reason)) => Err(NewError::InvalidBothNames {
            name: name.to_owned(),
            crate_reason,
            gleam_reason,
        }),
    }
}

fn crate_name_error(name: &str) -> Option<&'static str> {
    const KEYWORDS: &[&str] = &[
        "Self", "abstract", "as", "async", "await", "become", "box", "break", "const", "continue",
        "crate", "do", "dyn", "else", "enum", "extern", "false", "final", "fn", "for", "gen", "if",
        "impl", "in", "let", "loop", "macro", "match", "mod", "move", "mut", "override", "priv",
        "pub", "ref", "return", "self", "static", "struct", "super", "trait", "true", "try",
        "type", "typeof", "union", "unsafe", "unsized", "use", "virtual", "where", "while",
        "yield",
    ];
    let mut chars = name.chars();
    if name == "_"
        || !chars
            .next()
            .is_some_and(|first| first == '_' || first.is_ascii_alphabetic())
        || !chars.all(|character| character == '_' || character.is_ascii_alphanumeric())
    {
        return Some("it must be an ASCII Rust identifier");
    }
    KEYWORDS
        .contains(&name)
        .then_some("Rust keywords are not crate identifiers")
}

fn gleam_name_error(name: &str) -> Option<&'static str> {
    let mut chars = name.chars();
    if !chars.next().is_some_and(|first| first.is_ascii_lowercase())
        || !chars.all(|character| {
            character == '_' || character.is_ascii_lowercase() || character.is_ascii_digit()
        })
    {
        Some(
            "it must start with a lowercase ASCII letter and contain only lowercase letters, digits, or underscores",
        )
    } else {
        None
    }
}

#[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 invalid_names_report_crate_gleam_and_both_rules() {
        let crate_error = validate_name("type").err().map(|error| error.to_string());
        assert!(crate_error.is_some_and(|message| {
            message.contains("Rust crate identifier") && message.contains("[a-z][a-z0-9_]*")
        }));

        let gleam_error = validate_name("Hello").err().map(|error| error.to_string());
        assert!(gleam_error.is_some_and(|message| {
            message.contains("Gleam project name") && message.contains("[a-z][a-z0-9_]*")
        }));

        let both_error = validate_name("bad-name")
            .err()
            .map(|error| error.to_string());
        assert!(both_error.is_some_and(|message| {
            message.contains("fails both rules")
                && message.contains("Rust crate identifier")
                && message.contains("Gleam project name")
        }));
    }

    #[test]
    fn clap_takes_bare_name_and_rejects_removed_and_unknown_flags() {
        assert!(Cli::try_parse_from(["frame", "new", "valid_app"]).is_ok());
        assert!(Cli::try_parse_from(["frame", "new", "valid_app", "--frame-root", "."]).is_err());
        assert!(Cli::try_parse_from(["frame", "new", "valid_app", "--force"]).is_err());
    }

    #[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(())
    }
}