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.
13const 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/// Parsed command line for the Frame operator tool.
26#[derive(Debug, Parser)]
27#[command(name = "frame", version, about = "Frame application tooling")]
28pub struct Cli {
29    /// Operation to perform.
30    #[command(subcommand)]
31    pub command: Command,
32}
33
34/// Supported Frame operations.
35#[derive(Debug, Subcommand)]
36pub enum Command {
37    /// Generate a new Frame application without overwriting existing paths.
38    New {
39        /// Application and Gleam project name.
40        name: String,
41    },
42}
43
44/// Inputs to one deterministic scaffold generation.
45#[derive(Clone, Debug)]
46pub struct NewOptions {
47    /// Name used by the application and component.
48    pub name: String,
49    /// Parent directory beneath which the named project is created.
50    pub target_parent: PathBuf,
51}
52
53/// Typed refusal or generation failure from `frame new`.
54#[derive(Debug, Error)]
55pub enum NewError {
56    /// The name fails the Rust crate identifier rule.
57    #[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        /// Rejected input.
62        name: String,
63        /// Specific Rust identifier violation.
64        reason: &'static str,
65    },
66    /// The name fails the Gleam project-name rule.
67    #[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        /// Rejected input.
72        name: String,
73        /// Specific Gleam naming violation.
74        reason: &'static str,
75    },
76    /// Both language naming rules reject the name.
77    #[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        /// Rejected input.
82        name: String,
83        /// Specific Rust identifier violation.
84        crate_reason: &'static str,
85        /// Specific Gleam naming violation.
86        gleam_reason: &'static str,
87    },
88    /// The target is protected from replacement.
89    #[error(
90        "target directory `{path}` already exists; frame new never overwrites and has no --force"
91    )]
92    TargetExists {
93        /// Existing path protected from overwrite.
94        path: PathBuf,
95    },
96    /// A named filesystem generation operation failed.
97    #[error("generation failed while {operation} `{path}`: {source}")]
98    Generation {
99        /// Operation that failed.
100        operation: &'static str,
101        /// File or directory involved.
102        path: PathBuf,
103        /// Underlying filesystem error.
104        #[source]
105        source: io::Error,
106    },
107    /// Generation failed and atomic cleanup also failed.
108    #[error(
109        "generation failed while {operation} `{path}`: {source}; removing partial target `{target}` also failed: {cleanup}"
110    )]
111    GenerationAndCleanup {
112        /// Original operation that failed.
113        operation: &'static str,
114        /// Original file or directory involved.
115        path: PathBuf,
116        /// Original filesystem error.
117        source: io::Error,
118        /// Partial target whose cleanup failed.
119        target: PathBuf,
120        /// Cleanup filesystem error.
121        cleanup: io::Error,
122    },
123}
124
125/// Executes a parsed CLI command.
126///
127/// # Errors
128///
129/// Returns a typed refusal or filesystem failure from scaffold generation.
130pub 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
146/// Atomically generates one application tree.
147///
148/// # Errors
149///
150/// Refuses invalid names, invalid roots, existing targets, and any named I/O failure.
151pub 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}