use std::fs;
use std::path::Path;
mod templates {
pub const CARGO_TOML: &str = include_str!("templates/Cargo.toml.tmpl");
pub const MAIN_RS: &str = include_str!("templates/main.rs.tmpl");
pub const AUTUMN_TOML: &str = include_str!("templates/autumn.toml.tmpl");
pub const DOCKERFILE: &str = include_str!("templates/Dockerfile.tmpl");
pub const DOCKERIGNORE: &str = include_str!("templates/.dockerignore.tmpl");
pub const BUILD_RS: &str = include_str!("templates/build.rs.tmpl");
pub const INPUT_CSS: &str = include_str!("templates/input.css.tmpl");
pub const TAILWIND_CONFIG: &str = include_str!("templates/tailwind.config.js.tmpl");
pub const GITIGNORE: &str = include_str!("templates/gitignore.tmpl");
}
#[derive(Debug, thiserror::Error)]
pub enum NewError {
#[error("invalid project name '{0}': {1}")]
InvalidName(String, String),
#[error("directory '{0}' already exists")]
AlreadyExists(String),
#[error("failed to create project: {0}")]
Io(#[from] std::io::Error),
}
pub fn run(name: &str) {
let cwd = std::env::current_dir().unwrap_or_else(|e| {
eprintln!("Error: cannot determine current directory: {e}");
std::process::exit(1);
});
if let Err(e) = generate(name, &cwd) {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
pub fn generate(name: &str, parent_dir: &Path) -> Result<(), NewError> {
validate_name(name)?;
let project_dir = parent_dir.join(name);
if project_dir.exists() {
return Err(NewError::AlreadyExists(name.to_owned()));
}
let crate_name = name.replace('-', "_");
let autumn_version = env!("CARGO_PKG_VERSION");
fs::create_dir_all(project_dir.join("src"))?;
fs::create_dir_all(project_dir.join("static/css"))?;
fs::create_dir_all(project_dir.join("migrations"))?;
let render = |template: &str| -> String {
template
.replace("{{project_name}}", name)
.replace("{{crate_name}}", &crate_name)
.replace("{{autumn_version}}", autumn_version)
};
fs::write(
project_dir.join("Cargo.toml"),
render(templates::CARGO_TOML),
)?;
fs::write(project_dir.join("src/main.rs"), render(templates::MAIN_RS))?;
fs::write(
project_dir.join("autumn.toml"),
render(templates::AUTUMN_TOML),
)?;
fs::write(
project_dir.join("Dockerfile"),
render(templates::DOCKERFILE),
)?;
fs::write(
project_dir.join(".dockerignore"),
render(templates::DOCKERIGNORE),
)?;
fs::write(project_dir.join("build.rs"), render(templates::BUILD_RS))?;
fs::write(
project_dir.join("static/css/input.css"),
render(templates::INPUT_CSS),
)?;
fs::write(
project_dir.join("tailwind.config.js"),
render(templates::TAILWIND_CONFIG),
)?;
fs::write(project_dir.join(".gitignore"), render(templates::GITIGNORE))?;
fs::write(project_dir.join("migrations/.gitkeep"), "")?;
println!(" Created {name}/");
println!(" Created {name}/Cargo.toml");
println!(" Created {name}/autumn.toml");
println!(" Created {name}/Dockerfile");
println!(" Created {name}/.dockerignore");
println!(" Created {name}/build.rs");
println!(" Created {name}/src/main.rs");
println!(" Created {name}/static/css/input.css");
println!(" Created {name}/tailwind.config.js");
println!(" Created {name}/.gitignore");
println!(" Created {name}/migrations/");
println!();
println!("Get started:");
println!(" cd {name}");
println!(" cargo run");
println!();
println!("Your app will be available at http://localhost:3000");
Ok(())
}
const KEYWORDS: &[&str] = &[
"Self", "abstract", "as", "async", "await", "become", "box", "break", "const", "continue",
"crate", "do", "dyn", "else", "enum", "extern", "false", "final", "fn", "for", "if", "impl",
"in", "let", "loop", "macro", "match", "mod", "move", "mut", "override", "priv", "pub", "ref",
"return", "self", "static", "struct", "super", "trait", "true", "try", "type", "typeof",
"unsafe", "unsized", "use", "virtual", "where", "while", "yield",
];
fn validate_name(name: &str) -> Result<(), NewError> {
if name.is_empty() {
return Err(NewError::InvalidName(
name.to_owned(),
"name cannot be empty".into(),
));
}
let first = name.chars().next().expect("checked non-empty");
if !first.is_ascii_alphabetic() {
return Err(NewError::InvalidName(
name.to_owned(),
"must start with a letter".into(),
));
}
if let Some(bad) = name
.chars()
.find(|c| !c.is_ascii_alphanumeric() && *c != '-' && *c != '_')
{
return Err(NewError::InvalidName(
name.to_owned(),
format!("contains invalid character '{bad}'"),
));
}
if KEYWORDS.contains(&name) {
return Err(NewError::InvalidName(
name.to_owned(),
format!("'{name}' is a Rust keyword"),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn valid_name_simple() {
assert!(validate_name("myapp").is_ok());
}
#[test]
fn valid_name_with_hyphens() {
assert!(validate_name("my-app").is_ok());
}
#[test]
fn valid_name_with_underscores() {
assert!(validate_name("my_app").is_ok());
}
#[test]
fn valid_name_with_digits() {
assert!(validate_name("app2").is_ok());
}
#[test]
fn empty_name_rejected() {
let err = validate_name("").unwrap_err();
assert!(err.to_string().contains("empty"));
}
#[test]
fn starts_with_digit_rejected() {
let err = validate_name("3app").unwrap_err();
assert!(err.to_string().contains("start with a letter"));
}
#[test]
fn starts_with_hyphen_rejected() {
let err = validate_name("-app").unwrap_err();
assert!(err.to_string().contains("start with a letter"));
}
#[test]
fn special_chars_rejected() {
let err = validate_name("my app!").unwrap_err();
assert!(err.to_string().contains("invalid character"));
}
#[test]
fn keyword_rejected() {
let err = validate_name("fn").unwrap_err();
assert!(err.to_string().contains("keyword"));
}
#[test]
fn keyword_async_rejected() {
let err = validate_name("async").unwrap_err();
assert!(err.to_string().contains("keyword"));
}
#[test]
fn generates_all_expected_files() {
let tmp = TempDir::new().unwrap();
generate("test-app", tmp.path()).unwrap();
let p = tmp.path().join("test-app");
assert!(p.join("Cargo.toml").is_file());
assert!(p.join("src/main.rs").is_file());
assert!(p.join("autumn.toml").is_file());
assert!(p.join("Dockerfile").is_file());
assert!(p.join(".dockerignore").is_file());
assert!(p.join("build.rs").is_file());
assert!(p.join(".gitignore").is_file());
assert!(p.join("static/css/input.css").is_file());
assert!(p.join("tailwind.config.js").is_file());
assert!(p.join("migrations/.gitkeep").is_file());
assert!(!p.join("src/lib.rs").exists());
assert!(!p.join("src/client.rs").exists());
}
#[test]
fn cargo_toml_has_project_name() {
let tmp = TempDir::new().unwrap();
generate("my-cool-app", tmp.path()).unwrap();
let content = fs::read_to_string(tmp.path().join("my-cool-app/Cargo.toml")).unwrap();
assert!(content.contains(r#"name = "my-cool-app""#));
assert!(content.contains("autumn-web = "));
}
#[test]
fn cargo_toml_has_autumn_version() {
let tmp = TempDir::new().unwrap();
generate("ver-check", tmp.path()).unwrap();
let content = fs::read_to_string(tmp.path().join("ver-check/Cargo.toml")).unwrap();
let expected = format!(r#"autumn-web = "{}""#, env!("CARGO_PKG_VERSION"));
assert!(content.contains(&expected));
}
#[test]
fn main_rs_has_sample_routes() {
let tmp = TempDir::new().unwrap();
generate("route-check", tmp.path()).unwrap();
let content = fs::read_to_string(tmp.path().join("route-check/src/main.rs")).unwrap();
assert!(content.contains(r#"#[get("/")]"#));
assert!(content.contains(r#"#[get("/hello")]"#));
assert!(content.contains(r#"#[get("/hello/{name}")]"#));
assert!(content.contains("#[autumn_web::main]"));
assert!(content.contains("autumn_web::app()"));
}
#[test]
fn autumn_toml_has_defaults() {
let tmp = TempDir::new().unwrap();
generate("cfg-check", tmp.path()).unwrap();
let content = fs::read_to_string(tmp.path().join("cfg-check/autumn.toml")).unwrap();
assert!(content.contains("port = 3000"));
assert!(content.contains(r#"host = "127.0.0.1""#));
assert!(content.contains(r#"level = "info""#));
assert!(content.contains(r#"path = "/health""#));
}
#[test]
fn autumn_toml_has_crate_name_in_db_url() {
let tmp = TempDir::new().unwrap();
generate("my-db-app", tmp.path()).unwrap();
let content = fs::read_to_string(tmp.path().join("my-db-app/autumn.toml")).unwrap();
assert!(content.contains("my_db_app"));
}
#[test]
fn gitignore_excludes_target_and_css() {
let tmp = TempDir::new().unwrap();
generate("gi-check", tmp.path()).unwrap();
let content = fs::read_to_string(tmp.path().join("gi-check/.gitignore")).unwrap();
assert!(content.contains("/target"));
assert!(content.contains("static/css/autumn.css"));
assert!(!content.contains("static/autumn/"));
}
#[test]
fn generated_build_rs_reruns_on_css_input_changes() {
let tmp = TempDir::new().unwrap();
generate("css-watch-check", tmp.path()).unwrap();
let content = fs::read_to_string(tmp.path().join("css-watch-check/build.rs")).unwrap();
assert!(content.contains("cargo:rerun-if-changed=static/css/input.css"));
assert!(content.contains("cargo:rerun-if-changed=target/autumn/tailwindcss"));
assert!(content.contains("cargo:rerun-if-env-changed=PATH"));
}
#[test]
fn no_unsubstituted_placeholders() {
let tmp = TempDir::new().unwrap();
generate("placeholder-check", tmp.path()).unwrap();
let p = tmp.path().join("placeholder-check");
for entry in walkdir(&p) {
let content = fs::read_to_string(&entry).unwrap();
assert!(
!content.contains("{{"),
"unsubstituted placeholder in {}",
entry.display()
);
}
}
#[test]
fn already_exists_error() {
let tmp = TempDir::new().unwrap();
generate("dupe-check", tmp.path()).unwrap();
let err = generate("dupe-check", tmp.path()).unwrap_err();
assert!(matches!(err, NewError::AlreadyExists(_)));
assert!(err.to_string().contains("already exists"));
}
#[test]
fn invalid_name_error() {
let tmp = TempDir::new().unwrap();
let err = generate("123bad", tmp.path()).unwrap_err();
assert!(matches!(err, NewError::InvalidName(_, _)));
}
fn walkdir(dir: &Path) -> Vec<std::path::PathBuf> {
let mut files = Vec::new();
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
files.extend(walkdir(&path));
} else {
files.push(path);
}
}
}
files
}
}