use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use thiserror::Error;
use super::names::validate_name;
use super::templates;
#[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 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)?;
}
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;
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),
),
(
"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(())
}
}