Skip to main content

nifty_cli/
create.rs

1//! `nf create <name>`: start a new app from the starter template.
2
3use std::fs;
4use std::io::ErrorKind;
5use std::path::Path;
6use std::process::ExitCode;
7
8use nifty_syntax::project::ENTRY_FILE;
9
10use crate::CommandResult;
11use crate::error::CliError;
12
13const TEMPLATE: &str = include_str!("../templates/main.nf");
14const TITLE_PLACEHOLDER: &str = "{{title}}";
15
16pub(crate) fn run(name: &str) -> CommandResult {
17    validate_name(name)?;
18    let dir = Path::new(name);
19    fs::create_dir(dir).map_err(|error| match error.kind() {
20        ErrorKind::AlreadyExists => CliError::new(format!("{name} already exists")),
21        _ => CliError::new(format!("can't create {name}: {error}")),
22    })?;
23
24    let entry = dir.join(ENTRY_FILE);
25    fs::write(&entry, starter_source(name))
26        .map_err(|error| CliError::new(format!("can't write {}: {error}", entry.display())))?;
27
28    println!("Created {name}/{ENTRY_FILE}\n\nRun it with:\n  nf run {name}");
29    Ok(ExitCode::SUCCESS)
30}
31
32/// Names become folder names and window titles, so they stay simple and portable.
33fn validate_name(name: &str) -> Result<(), CliError> {
34    let mut chars = name.chars();
35    let starts_with_letter = chars.next().is_some_and(|c| c.is_ascii_alphabetic());
36    let rest_is_valid = chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
37    if starts_with_letter && rest_is_valid {
38        Ok(())
39    } else {
40        Err(CliError::new(format!(
41            "`{name}` isn't a valid app name; start with a letter and use only letters, digits, `-` and `_`"
42        )))
43    }
44}
45
46fn starter_source(name: &str) -> String {
47    TEMPLATE.replace(TITLE_PLACEHOLDER, name)
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53
54    #[test]
55    fn accepts_simple_names() {
56        for name in ["todo", "Todo2", "my-app", "my_app"] {
57            assert!(validate_name(name).is_ok(), "{name} should be valid");
58        }
59    }
60
61    #[test]
62    fn rejects_names_that_are_not_portable_folder_names() {
63        for name in ["", "2do", "-app", "my app", "a/b", "../x", "app.nf", "café"] {
64            assert!(validate_name(name).is_err(), "{name:?} should be rejected");
65        }
66    }
67
68    #[test]
69    fn starter_source_is_a_valid_program_titled_after_the_app() {
70        let source = starter_source("todo");
71        assert!(source.contains(r#"window("todo","#));
72        assert_eq!(
73            nifty_syntax::parse(&source, nifty_syntax::FileId::default()).diagnostics,
74            []
75        );
76    }
77}