Skip to main content

frame_cli/scaffold/
generate.rs

1//! Deterministic, atomic generation of one Frame application tree.
2
3use 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/// Inputs to one deterministic scaffold generation.
13#[derive(Clone, Debug)]
14pub struct NewOptions {
15    /// Name used by the application and component.
16    pub name: String,
17    /// Parent directory beneath which the named project is created.
18    pub target_parent: PathBuf,
19}
20
21/// Typed refusal or generation failure from `frame new`.
22#[derive(Debug, Error)]
23pub enum NewError {
24    /// The name fails the Rust crate identifier rule.
25    #[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        /// Rejected input.
30        name: String,
31        /// Specific Rust identifier violation.
32        reason: &'static str,
33    },
34    /// The name fails the Gleam project-name rule.
35    #[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        /// Rejected input.
40        name: String,
41        /// Specific Gleam naming violation.
42        reason: &'static str,
43    },
44    /// Both language naming rules reject the name.
45    #[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        /// Rejected input.
50        name: String,
51        /// Specific Rust identifier violation.
52        crate_reason: &'static str,
53        /// Specific Gleam naming violation.
54        gleam_reason: &'static str,
55    },
56    /// The target is protected from replacement.
57    #[error(
58        "target directory `{path}` already exists; frame new never overwrites and has no --force"
59    )]
60    TargetExists {
61        /// Existing path protected from overwrite.
62        path: PathBuf,
63    },
64    /// A named filesystem generation operation failed.
65    #[error("generation failed while {operation} `{path}`: {source}")]
66    Generation {
67        /// Operation that failed.
68        operation: &'static str,
69        /// File or directory involved.
70        path: PathBuf,
71        /// Underlying filesystem error.
72        #[source]
73        source: io::Error,
74    },
75    /// Generation failed and atomic cleanup also failed.
76    #[error(
77        "generation failed while {operation} `{path}`: {source}; removing partial target `{target}` also failed: {cleanup}"
78    )]
79    GenerationAndCleanup {
80        /// Original operation that failed.
81        operation: &'static str,
82        /// Original file or directory involved.
83        path: PathBuf,
84        /// Original filesystem error.
85        source: io::Error,
86        /// Partial target whose cleanup failed.
87        target: PathBuf,
88        /// Cleanup filesystem error.
89        cleanup: io::Error,
90    },
91}
92
93/// Atomically generates one application tree.
94///
95/// # Errors
96///
97/// Refuses invalid names, invalid roots, existing targets, and any named I/O failure.
98pub 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    // The vendored SDK artifact is written byte-exact, outside render():
152    // templating a third-party build output would corrupt it. It is fully
153    // self-contained (WebAssembly inlined) — one file is the whole vendor
154    // story.
155    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    // Write order is load-bearing for the page: every `page/src` source is
237    // written BEFORE its pre-compiled `page/dist` module, so a fresh
238    // scaffold's compiled page is never older than its sources and
239    // `frame run`'s staleness comparison starts from "up to date".
240    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        // The pre-compiled page modules: byte-exact pinned-tsc output of the
278        // page/src sources above (see templates.rs), rendered through the
279        // same placeholder pass — tsc passes string content through
280        // verbatim, so render-then-compile and compile-then-render agree
281        // byte for byte. Emitting them completes the servable root at
282        // scaffold time: the first `frame run` needs no npm.
283        (
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}