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