1use std::fs;
4use std::io;
5use std::path::{Path, PathBuf};
6
7use clap::{Parser, Subcommand};
8use thiserror::Error;
9
10const ROOT_CARGO: &str = include_str!("../templates/project-Cargo.toml");
17const ROOT_GITIGNORE: &str = include_str!("../templates/project-gitignore");
18const FRAME_TOML: &str = include_str!("../templates/frame.toml");
19const HOST_CARGO: &str = include_str!("../templates/host-Cargo.toml");
20const BUILD_RS: &str = include_str!("../templates/build.rs");
21const HOST_LIB: &str = include_str!("../templates/host-lib.rs");
22const HOST_MAIN: &str = include_str!("../templates/host-main.rs");
23const HOST_E2E: &str = include_str!("../templates/host-e2e.rs");
24const GLEAM_TOML: &str = include_str!("../templates/gleam.toml");
25const COMPONENT: &str = include_str!("../templates/component.gleam");
26const COMPONENT_FFI: &str = include_str!("../templates/component_ffi.erl");
27const COMPONENT_TEST: &str = include_str!("../templates/component_test.gleam");
28const README: &str = include_str!("../templates/README.md");
29const PAGE_PACKAGE_JSON: &str = include_str!("../templates/page-package.json");
30const PAGE_TSCONFIG: &str = include_str!("../templates/page-tsconfig.json");
31const PAGE_VITE_CONFIG: &str = include_str!("../templates/page-vite.config.ts");
32const PAGE_INDEX_HTML: &str = include_str!("../templates/page-index.html");
33const PAGE_CONFIG_TS: &str = include_str!("../templates/page-config.ts");
34const PAGE_APP_STATUS_TS: &str = include_str!("../templates/page-app-status.ts");
35const PAGE_CONNECTION_TS: &str = include_str!("../templates/page-connection.ts");
36const PAGE_MAIN_TS: &str = include_str!("../templates/page-main.ts");
37const PAGE_E2E_MJS: &str = include_str!("../templates/page-e2e.mjs");
38
39#[derive(Debug, Parser)]
41#[command(name = "frame", version, about = "Frame application tooling")]
42pub struct Cli {
43 #[command(subcommand)]
45 pub command: Command,
46}
47
48#[derive(Debug, Subcommand)]
50pub enum Command {
51 New {
53 name: String,
55 },
56}
57
58#[derive(Clone, Debug)]
60pub struct NewOptions {
61 pub name: String,
63 pub target_parent: PathBuf,
65}
66
67#[derive(Debug, Error)]
69pub enum NewError {
70 #[error(
72 "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)"
73 )]
74 InvalidCrateName {
75 name: String,
77 reason: &'static str,
79 },
80 #[error(
82 "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)"
83 )]
84 InvalidGleamName {
85 name: String,
87 reason: &'static str,
89 },
90 #[error(
92 "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"
93 )]
94 InvalidBothNames {
95 name: String,
97 crate_reason: &'static str,
99 gleam_reason: &'static str,
101 },
102 #[error(
104 "target directory `{path}` already exists; frame new never overwrites and has no --force"
105 )]
106 TargetExists {
107 path: PathBuf,
109 },
110 #[error("generation failed while {operation} `{path}`: {source}")]
112 Generation {
113 operation: &'static str,
115 path: PathBuf,
117 #[source]
119 source: io::Error,
120 },
121 #[error(
123 "generation failed while {operation} `{path}`: {source}; removing partial target `{target}` also failed: {cleanup}"
124 )]
125 GenerationAndCleanup {
126 operation: &'static str,
128 path: PathBuf,
130 source: io::Error,
132 target: PathBuf,
134 cleanup: io::Error,
136 },
137}
138
139pub fn execute(cli: Cli) -> Result<PathBuf, NewError> {
145 match cli.command {
146 Command::New { name } => {
147 let options = NewOptions {
148 name,
149 target_parent: std::env::current_dir().map_err(|source| NewError::Generation {
150 operation: "reading the current directory for",
151 path: PathBuf::from("."),
152 source,
153 })?,
154 };
155 generate(&options)
156 }
157 }
158}
159
160pub fn generate(options: &NewOptions) -> Result<PathBuf, NewError> {
166 generate_with_hook(options, |_| Ok(()))
167}
168
169fn generate_with_hook(
170 options: &NewOptions,
171 mut before_write: impl FnMut(&Path) -> io::Result<()>,
172) -> Result<PathBuf, NewError> {
173 validate_name(&options.name)?;
174 let target = options.target_parent.join(&options.name);
175 if target.exists() {
176 return Err(NewError::TargetExists { path: target });
177 }
178 fs::create_dir(&target).map_err(|source| NewError::Generation {
179 operation: "creating target directory",
180 path: target.clone(),
181 source,
182 })?;
183
184 let result = write_tree(&target, &options.name, &mut before_write);
185 if let Err(NewError::Generation {
186 operation,
187 path,
188 source,
189 }) = result
190 {
191 return match fs::remove_dir_all(&target) {
192 Ok(()) => Err(NewError::Generation {
193 operation,
194 path,
195 source,
196 }),
197 Err(cleanup) => Err(NewError::GenerationAndCleanup {
198 operation,
199 path,
200 source,
201 target,
202 cleanup,
203 }),
204 };
205 }
206 result?;
207 Ok(target)
208}
209
210fn write_tree(
211 target: &Path,
212 name: &str,
213 before_write: &mut impl FnMut(&Path) -> io::Result<()>,
214) -> Result<(), NewError> {
215 let gleam_name = name;
216 let files = [
217 ("Cargo.toml", render(ROOT_CARGO, name, gleam_name)),
218 (".gitignore", render(ROOT_GITIGNORE, name, gleam_name)),
219 ("frame.toml", render(FRAME_TOML, name, gleam_name)),
220 ("README.md", render(README, name, gleam_name)),
221 ("host/Cargo.toml", render(HOST_CARGO, name, gleam_name)),
222 ("host/build.rs", render(BUILD_RS, name, gleam_name)),
223 ("host/src/lib.rs", render(HOST_LIB, name, gleam_name)),
224 ("host/src/main.rs", render(HOST_MAIN, name, gleam_name)),
225 ("host/tests/e2e.rs", render(HOST_E2E, name, gleam_name)),
226 ("component/gleam.toml", render(GLEAM_TOML, name, gleam_name)),
227 (
228 &format!("component/src/{gleam_name}.gleam"),
229 render(COMPONENT, name, gleam_name),
230 ),
231 (
232 &format!("component/src/{gleam_name}_ffi.erl"),
233 render(COMPONENT_FFI, name, gleam_name),
234 ),
235 (
236 &format!("component/test/{gleam_name}_test.gleam"),
237 render(COMPONENT_TEST, name, gleam_name),
238 ),
239 (
240 "page/package.json",
241 render(PAGE_PACKAGE_JSON, name, gleam_name),
242 ),
243 (
244 "page/tsconfig.json",
245 render(PAGE_TSCONFIG, name, gleam_name),
246 ),
247 (
248 "page/vite.config.ts",
249 render(PAGE_VITE_CONFIG, name, gleam_name),
250 ),
251 ("page/index.html", render(PAGE_INDEX_HTML, name, gleam_name)),
252 (
253 "page/src/config.ts",
254 render(PAGE_CONFIG_TS, name, gleam_name),
255 ),
256 (
257 "page/src/app-status.ts",
258 render(PAGE_APP_STATUS_TS, name, gleam_name),
259 ),
260 (
261 "page/src/connection.ts",
262 render(PAGE_CONNECTION_TS, name, gleam_name),
263 ),
264 ("page/src/main.ts", render(PAGE_MAIN_TS, name, gleam_name)),
265 (
266 "page/scripts/e2e.mjs",
267 render(PAGE_E2E_MJS, name, gleam_name),
268 ),
269 ];
270 for (relative, contents) in files {
271 let path = target.join(relative);
272 before_write(&path).map_err(|source| NewError::Generation {
273 operation: "preparing generated file",
274 path: path.clone(),
275 source,
276 })?;
277 let parent = path.parent().ok_or_else(|| NewError::Generation {
278 operation: "finding parent for generated file",
279 path: path.clone(),
280 source: io::Error::other("generated path had no parent"),
281 })?;
282 fs::create_dir_all(parent).map_err(|source| NewError::Generation {
283 operation: "creating generated subdirectory for",
284 path: path.clone(),
285 source,
286 })?;
287 fs::write(&path, contents).map_err(|source| NewError::Generation {
288 operation: "writing generated file",
289 path,
290 source,
291 })?;
292 }
293 Ok(())
294}
295
296fn render(template: &str, name: &str, gleam_name: &str) -> String {
297 template
298 .replace("{{NAME}}", name)
299 .replace("{{GLEAM_NAME}}", gleam_name)
300}
301
302fn validate_name(name: &str) -> Result<(), NewError> {
303 let crate_error = crate_name_error(name);
304 let gleam_error = gleam_name_error(name);
305 match (crate_error, gleam_error) {
306 (None, None) => Ok(()),
307 (Some(crate_reason), None) => Err(NewError::InvalidCrateName {
308 name: name.to_owned(),
309 reason: crate_reason,
310 }),
311 (None, Some(gleam_reason)) => Err(NewError::InvalidGleamName {
312 name: name.to_owned(),
313 reason: gleam_reason,
314 }),
315 (Some(crate_reason), Some(gleam_reason)) => Err(NewError::InvalidBothNames {
316 name: name.to_owned(),
317 crate_reason,
318 gleam_reason,
319 }),
320 }
321}
322
323fn crate_name_error(name: &str) -> Option<&'static str> {
324 const KEYWORDS: &[&str] = &[
325 "Self", "abstract", "as", "async", "await", "become", "box", "break", "const", "continue",
326 "crate", "do", "dyn", "else", "enum", "extern", "false", "final", "fn", "for", "gen", "if",
327 "impl", "in", "let", "loop", "macro", "match", "mod", "move", "mut", "override", "priv",
328 "pub", "ref", "return", "self", "static", "struct", "super", "trait", "true", "try",
329 "type", "typeof", "union", "unsafe", "unsized", "use", "virtual", "where", "while",
330 "yield",
331 ];
332 let mut chars = name.chars();
333 if name == "_"
334 || !chars
335 .next()
336 .is_some_and(|first| first == '_' || first.is_ascii_alphabetic())
337 || !chars.all(|character| character == '_' || character.is_ascii_alphanumeric())
338 {
339 return Some("it must be an ASCII Rust identifier");
340 }
341 KEYWORDS
342 .contains(&name)
343 .then_some("Rust keywords are not crate identifiers")
344}
345
346fn gleam_name_error(name: &str) -> Option<&'static str> {
347 let mut chars = name.chars();
348 if !chars.next().is_some_and(|first| first.is_ascii_lowercase())
349 || !chars.all(|character| {
350 character == '_' || character.is_ascii_lowercase() || character.is_ascii_digit()
351 })
352 {
353 Some(
354 "it must start with a lowercase ASCII letter and contain only lowercase letters, digits, or underscores",
355 )
356 } else {
357 None
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use std::time::{SystemTime, UNIX_EPOCH};
365
366 fn temp(name: &str) -> Result<PathBuf, std::time::SystemTimeError> {
367 let unique = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
368 Ok(std::env::temp_dir().join(format!("frame-cli-{name}-{unique}")))
369 }
370
371 #[test]
372 fn invalid_names_report_crate_gleam_and_both_rules() {
373 let crate_error = validate_name("type").err().map(|error| error.to_string());
374 assert!(crate_error.is_some_and(|message| {
375 message.contains("Rust crate identifier") && message.contains("[a-z][a-z0-9_]*")
376 }));
377
378 let gleam_error = validate_name("Hello").err().map(|error| error.to_string());
379 assert!(gleam_error.is_some_and(|message| {
380 message.contains("Gleam project name") && message.contains("[a-z][a-z0-9_]*")
381 }));
382
383 let both_error = validate_name("bad-name")
384 .err()
385 .map(|error| error.to_string());
386 assert!(both_error.is_some_and(|message| {
387 message.contains("fails both rules")
388 && message.contains("Rust crate identifier")
389 && message.contains("Gleam project name")
390 }));
391 }
392
393 #[test]
394 fn clap_takes_bare_name_and_rejects_removed_and_unknown_flags() {
395 assert!(Cli::try_parse_from(["frame", "new", "valid_app"]).is_ok());
396 assert!(Cli::try_parse_from(["frame", "new", "valid_app", "--frame-root", "."]).is_err());
397 assert!(Cli::try_parse_from(["frame", "new", "valid_app", "--force"]).is_err());
398 }
399
400 #[test]
401 fn injected_mid_generation_failure_removes_partial_tree()
402 -> Result<(), Box<dyn std::error::Error>> {
403 let parent = temp("atomic")?;
404 fs::create_dir_all(&parent)?;
405 let options = NewOptions {
406 name: "atomic_app".to_owned(),
407 target_parent: parent.clone(),
408 };
409 let result = generate_with_hook(&options, |path| {
410 if path.ends_with("host/build.rs") {
411 Err(io::Error::new(
412 io::ErrorKind::PermissionDenied,
413 "injected unwritable subpath",
414 ))
415 } else {
416 Ok(())
417 }
418 });
419 assert!(matches!(result, Err(NewError::Generation { .. })));
420 assert!(!parent.join("atomic_app").exists());
421 fs::remove_dir_all(parent)?;
422 Ok(())
423 }
424}