use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use clap::{Parser, Subcommand};
use thiserror::Error;
const ROOT_CARGO: &str = include_str!("../templates/project-Cargo.toml");
const ROOT_GITIGNORE: &str = include_str!("../templates/project-gitignore");
const FRAME_TOML: &str = include_str!("../templates/frame.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");
const PAGE_PACKAGE_JSON: &str = include_str!("../templates/page-package.json");
const PAGE_TSCONFIG: &str = include_str!("../templates/page-tsconfig.json");
const PAGE_VITE_CONFIG: &str = include_str!("../templates/page-vite.config.ts");
const PAGE_INDEX_HTML: &str = include_str!("../templates/page-index.html");
const PAGE_CONFIG_TS: &str = include_str!("../templates/page-config.ts");
const PAGE_APP_STATUS_TS: &str = include_str!("../templates/page-app-status.ts");
const PAGE_CONNECTION_TS: &str = include_str!("../templates/page-connection.ts");
const PAGE_MAIN_TS: &str = include_str!("../templates/page-main.ts");
const PAGE_E2E_MJS: &str = include_str!("../templates/page-e2e.mjs");
#[derive(Debug, Parser)]
#[command(name = "frame", version, about = "Frame application tooling")]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
New {
name: String,
},
}
#[derive(Clone, Debug)]
pub struct NewOptions {
pub name: String,
pub target_parent: PathBuf,
}
#[derive(Debug, Error)]
pub enum NewError {
#[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 {
name: String,
reason: &'static str,
},
#[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 {
name: String,
reason: &'static str,
},
#[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 {
name: String,
crate_reason: &'static str,
gleam_reason: &'static str,
},
#[error(
"target directory `{path}` already exists; frame new never overwrites and has no --force"
)]
TargetExists {
path: PathBuf,
},
#[error("generation failed while {operation} `{path}`: {source}")]
Generation {
operation: &'static str,
path: PathBuf,
#[source]
source: io::Error,
},
#[error(
"generation failed while {operation} `{path}`: {source}; removing partial target `{target}` also failed: {cleanup}"
)]
GenerationAndCleanup {
operation: &'static str,
path: PathBuf,
source: io::Error,
target: PathBuf,
cleanup: io::Error,
},
}
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)
}
}
}
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)),
(".gitignore", render(ROOT_GITIGNORE, name, gleam_name)),
("frame.toml", render(FRAME_TOML, 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),
),
(
"page/package.json",
render(PAGE_PACKAGE_JSON, name, gleam_name),
),
(
"page/tsconfig.json",
render(PAGE_TSCONFIG, name, gleam_name),
),
(
"page/vite.config.ts",
render(PAGE_VITE_CONFIG, name, gleam_name),
),
("page/index.html", render(PAGE_INDEX_HTML, name, gleam_name)),
(
"page/src/config.ts",
render(PAGE_CONFIG_TS, name, gleam_name),
),
(
"page/src/app-status.ts",
render(PAGE_APP_STATUS_TS, name, gleam_name),
),
(
"page/src/connection.ts",
render(PAGE_CONNECTION_TS, name, gleam_name),
),
("page/src/main.ts", render(PAGE_MAIN_TS, name, gleam_name)),
(
"page/scripts/e2e.mjs",
render(PAGE_E2E_MJS, 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(())
}
}