1use std::fs;
4use std::io;
5use std::path::{Path, PathBuf};
6
7use thiserror::Error;
8
9use super::names::validate_name;
10use super::templates;
11
12#[derive(Clone, Debug)]
14pub struct NewOptions {
15 pub name: String,
17 pub target_parent: PathBuf,
19}
20
21#[derive(Debug, Error)]
23pub enum NewError {
24 #[error(
26 "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)"
27 )]
28 InvalidCrateName {
29 name: String,
31 reason: &'static str,
33 },
34 #[error(
36 "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)"
37 )]
38 InvalidGleamName {
39 name: String,
41 reason: &'static str,
43 },
44 #[error(
46 "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"
47 )]
48 InvalidBothNames {
49 name: String,
51 crate_reason: &'static str,
53 gleam_reason: &'static str,
55 },
56 #[error(
58 "target directory `{path}` already exists; frame new never overwrites and has no --force"
59 )]
60 TargetExists {
61 path: PathBuf,
63 },
64 #[error("generation failed while {operation} `{path}`: {source}")]
66 Generation {
67 operation: &'static str,
69 path: PathBuf,
71 #[source]
73 source: io::Error,
74 },
75 #[error(
77 "generation failed while {operation} `{path}`: {source}; removing partial target `{target}` also failed: {cleanup}"
78 )]
79 GenerationAndCleanup {
80 operation: &'static str,
82 path: PathBuf,
84 source: io::Error,
86 target: PathBuf,
88 cleanup: io::Error,
90 },
91}
92
93pub fn generate(options: &NewOptions) -> Result<PathBuf, NewError> {
99 generate_with_hook(options, |_| Ok(()))
100}
101
102fn generate_with_hook(
103 options: &NewOptions,
104 mut before_write: impl FnMut(&Path) -> io::Result<()>,
105) -> Result<PathBuf, NewError> {
106 validate_name(&options.name)?;
107 let target = options.target_parent.join(&options.name);
108 if target.exists() {
109 return Err(NewError::TargetExists { path: target });
110 }
111 fs::create_dir(&target).map_err(|source| NewError::Generation {
112 operation: "creating target directory",
113 path: target.clone(),
114 source,
115 })?;
116
117 let result = write_tree(&target, &options.name, &mut before_write);
118 if let Err(NewError::Generation {
119 operation,
120 path,
121 source,
122 }) = result
123 {
124 return match fs::remove_dir_all(&target) {
125 Ok(()) => Err(NewError::Generation {
126 operation,
127 path,
128 source,
129 }),
130 Err(cleanup) => Err(NewError::GenerationAndCleanup {
131 operation,
132 path,
133 source,
134 target,
135 cleanup,
136 }),
137 };
138 }
139 result?;
140 Ok(target)
141}
142
143fn write_tree(
144 target: &Path,
145 name: &str,
146 before_write: &mut impl FnMut(&Path) -> io::Result<()>,
147) -> Result<(), NewError> {
148 for (relative, contents) in rendered_files(name) {
149 write_file(target, &relative, contents.as_bytes(), before_write)?;
150 }
151 write_file(
156 target,
157 "page/dist/vendor/liminal.js",
158 templates::PAGE_VENDOR_LIMINAL_JS.as_bytes(),
159 before_write,
160 )?;
161 Ok(())
162}
163
164fn rendered_files(name: &str) -> Vec<(String, String)> {
165 let mut files = host_and_component_files(name);
166 files.extend(page_files(name));
167 files
168}
169
170fn host_and_component_files(name: &str) -> Vec<(String, String)> {
171 use templates::render;
172 let gleam_name = name;
173 let component_source = format!("component/src/{gleam_name}.gleam");
174 let component_ffi = format!("component/src/{gleam_name}_ffi.erl");
175 let component_test = format!("component/test/{gleam_name}_test.gleam");
176 let files = [
177 (
178 "Cargo.toml",
179 render(templates::ROOT_CARGO, name, gleam_name),
180 ),
181 (
182 ".gitignore",
183 render(templates::ROOT_GITIGNORE, name, gleam_name),
184 ),
185 (
186 "frame.toml",
187 render(templates::FRAME_TOML, name, gleam_name),
188 ),
189 ("README.md", render(templates::README, name, gleam_name)),
190 (
191 "host/Cargo.toml",
192 render(templates::HOST_CARGO, name, gleam_name),
193 ),
194 (
195 "host/build.rs",
196 render(templates::BUILD_RS, name, gleam_name),
197 ),
198 (
199 "host/src/lib.rs",
200 render(templates::HOST_LIB, name, gleam_name),
201 ),
202 (
203 "host/src/main.rs",
204 render(templates::HOST_MAIN, name, gleam_name),
205 ),
206 (
207 "host/tests/e2e.rs",
208 render(templates::HOST_E2E, name, gleam_name),
209 ),
210 (
211 "component/gleam.toml",
212 render(templates::GLEAM_TOML, name, gleam_name),
213 ),
214 (
215 component_source.as_str(),
216 render(templates::COMPONENT, name, gleam_name),
217 ),
218 (
219 component_ffi.as_str(),
220 render(templates::COMPONENT_FFI, name, gleam_name),
221 ),
222 (
223 component_test.as_str(),
224 render(templates::COMPONENT_TEST, name, gleam_name),
225 ),
226 ];
227 files
228 .into_iter()
229 .map(|(relative, contents)| (relative.to_string(), contents))
230 .collect()
231}
232
233fn page_files(name: &str) -> Vec<(String, String)> {
234 use templates::render;
235 let gleam_name = name;
236 let files = [
241 (
242 "page/package.json",
243 render(templates::PAGE_PACKAGE_JSON, name, gleam_name),
244 ),
245 (
246 "page/tsconfig.json",
247 render(templates::PAGE_TSCONFIG, name, gleam_name),
248 ),
249 (
250 "page/src/config.ts",
251 render(templates::PAGE_CONFIG_TS, name, gleam_name),
252 ),
253 (
254 "page/src/app-status.ts",
255 render(templates::PAGE_APP_STATUS_TS, name, gleam_name),
256 ),
257 (
258 "page/src/connection.ts",
259 render(templates::PAGE_CONNECTION_TS, name, gleam_name),
260 ),
261 (
262 "page/src/main.ts",
263 render(templates::PAGE_MAIN_TS, name, gleam_name),
264 ),
265 (
266 "page/scripts/e2e.mjs",
267 render(templates::PAGE_E2E_MJS, name, gleam_name),
268 ),
269 (
270 "page/dist/index.html",
271 render(templates::PAGE_INDEX_HTML, name, gleam_name),
272 ),
273 (
274 "page/dist/styles.css",
275 render(templates::PAGE_STYLES_CSS, name, gleam_name),
276 ),
277 (
284 "page/dist/config.js",
285 render(templates::PAGE_DIST_CONFIG_JS, name, gleam_name),
286 ),
287 (
288 "page/dist/app-status.js",
289 render(templates::PAGE_DIST_APP_STATUS_JS, name, gleam_name),
290 ),
291 (
292 "page/dist/connection.js",
293 render(templates::PAGE_DIST_CONNECTION_JS, name, gleam_name),
294 ),
295 (
296 "page/dist/main.js",
297 render(templates::PAGE_DIST_MAIN_JS, name, gleam_name),
298 ),
299 ];
300 files
301 .into_iter()
302 .map(|(relative, contents)| (relative.to_string(), contents))
303 .collect()
304}
305
306fn write_file(
307 target: &Path,
308 relative: &str,
309 contents: &[u8],
310 before_write: &mut impl FnMut(&Path) -> io::Result<()>,
311) -> Result<(), NewError> {
312 let path = target.join(relative);
313 before_write(&path).map_err(|source| NewError::Generation {
314 operation: "preparing generated file",
315 path: path.clone(),
316 source,
317 })?;
318 let parent = path.parent().ok_or_else(|| NewError::Generation {
319 operation: "finding parent for generated file",
320 path: path.clone(),
321 source: io::Error::other("generated path had no parent"),
322 })?;
323 fs::create_dir_all(parent).map_err(|source| NewError::Generation {
324 operation: "creating generated subdirectory for",
325 path: path.clone(),
326 source,
327 })?;
328 fs::write(&path, contents).map_err(|source| NewError::Generation {
329 operation: "writing generated file",
330 path,
331 source,
332 })?;
333 Ok(())
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339 use std::time::{SystemTime, UNIX_EPOCH};
340
341 fn temp(name: &str) -> Result<PathBuf, std::time::SystemTimeError> {
342 let unique = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
343 Ok(std::env::temp_dir().join(format!("frame-cli-{name}-{unique}")))
344 }
345
346 #[test]
347 fn injected_mid_generation_failure_removes_partial_tree()
348 -> Result<(), Box<dyn std::error::Error>> {
349 let parent = temp("atomic")?;
350 fs::create_dir_all(&parent)?;
351 let options = NewOptions {
352 name: "atomic_app".to_owned(),
353 target_parent: parent.clone(),
354 };
355 let result = generate_with_hook(&options, |path| {
356 if path.ends_with("host/build.rs") {
357 Err(io::Error::new(
358 io::ErrorKind::PermissionDenied,
359 "injected unwritable subpath",
360 ))
361 } else {
362 Ok(())
363 }
364 });
365 assert!(matches!(result, Err(NewError::Generation { .. })));
366 assert!(!parent.join("atomic_app").exists());
367 fs::remove_dir_all(parent)?;
368 Ok(())
369 }
370}