Skip to main content

frame_cli/
lib.rs

1//! Testable implementation of the `frame` command-line interface.
2
3use std::fs;
4use std::io;
5use std::path::{Path, PathBuf};
6
7use clap::{Parser, Subcommand};
8use thiserror::Error;
9
10// Named project-Cargo.toml, not Cargo.toml: cargo package treats any
11// subdirectory containing a file with that exact name as a nested package
12// and silently excludes the whole directory from the publish tarball. The
13// same law names project-gitignore: cargo package applies ignore rules to
14// its own file list, so a literal `.gitignore` inside `templates/` would
15// alter what ships in the frame-cli tarball.
16const 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/// Parsed command line for the Frame operator tool.
40#[derive(Debug, Parser)]
41#[command(name = "frame", version, about = "Frame application tooling")]
42pub struct Cli {
43    /// Operation to perform.
44    #[command(subcommand)]
45    pub command: Command,
46}
47
48/// Supported Frame operations.
49#[derive(Debug, Subcommand)]
50pub enum Command {
51    /// Generate a new Frame application without overwriting existing paths.
52    New {
53        /// Application and Gleam project name.
54        name: String,
55    },
56}
57
58/// Inputs to one deterministic scaffold generation.
59#[derive(Clone, Debug)]
60pub struct NewOptions {
61    /// Name used by the application and component.
62    pub name: String,
63    /// Parent directory beneath which the named project is created.
64    pub target_parent: PathBuf,
65}
66
67/// Typed refusal or generation failure from `frame new`.
68#[derive(Debug, Error)]
69pub enum NewError {
70    /// The name fails the Rust crate identifier rule.
71    #[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        /// Rejected input.
76        name: String,
77        /// Specific Rust identifier violation.
78        reason: &'static str,
79    },
80    /// The name fails the Gleam project-name rule.
81    #[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        /// Rejected input.
86        name: String,
87        /// Specific Gleam naming violation.
88        reason: &'static str,
89    },
90    /// Both language naming rules reject the name.
91    #[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        /// Rejected input.
96        name: String,
97        /// Specific Rust identifier violation.
98        crate_reason: &'static str,
99        /// Specific Gleam naming violation.
100        gleam_reason: &'static str,
101    },
102    /// The target is protected from replacement.
103    #[error(
104        "target directory `{path}` already exists; frame new never overwrites and has no --force"
105    )]
106    TargetExists {
107        /// Existing path protected from overwrite.
108        path: PathBuf,
109    },
110    /// A named filesystem generation operation failed.
111    #[error("generation failed while {operation} `{path}`: {source}")]
112    Generation {
113        /// Operation that failed.
114        operation: &'static str,
115        /// File or directory involved.
116        path: PathBuf,
117        /// Underlying filesystem error.
118        #[source]
119        source: io::Error,
120    },
121    /// Generation failed and atomic cleanup also failed.
122    #[error(
123        "generation failed while {operation} `{path}`: {source}; removing partial target `{target}` also failed: {cleanup}"
124    )]
125    GenerationAndCleanup {
126        /// Original operation that failed.
127        operation: &'static str,
128        /// Original file or directory involved.
129        path: PathBuf,
130        /// Original filesystem error.
131        source: io::Error,
132        /// Partial target whose cleanup failed.
133        target: PathBuf,
134        /// Cleanup filesystem error.
135        cleanup: io::Error,
136    },
137}
138
139/// Executes a parsed CLI command.
140///
141/// # Errors
142///
143/// Returns a typed refusal or filesystem failure from scaffold generation.
144pub 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
160/// Atomically generates one application tree.
161///
162/// # Errors
163///
164/// Refuses invalid names, invalid roots, existing targets, and any named I/O failure.
165pub 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}