runique 3.0.2

A Django-inspired web framework for Rust with ORM, templates, and comprehensive security middleware
Documentation
//! `new` command — scaffolds a new Runique project (files, dependencies, SECRET_KEY).
use crate::utils::trad::{t, tf};
use anyhow::Result;
use rand::RngExt;
use std::{fmt::Write, fs, path::Path};

/// Scaffolds a new Runique project named `name`: creates the standard directory
/// layout, writes the bundled example files (views, forms, templates, CSS,
/// `Cargo.toml`), and generates a fresh random `SECRET_KEY` into `.env`.
/// Fails if `name` is invalid or a folder with that name already exists.
pub fn create_new_project(name: &str) -> Result<()> {
    validate_project_name(name)?;

    let project_dir = Path::new(name);
    if project_dir.exists() {
        anyhow::bail!("{}", tf("cli.folder_exists", &[&name]));
    }

    println!("🦀 {}", tf("cli.creating_project", &[&name]));
    let runique_version = env!("CARGO_PKG_VERSION");

    // Create all necessary folders
    create_project_dirs(project_dir)?;

    // Copy static files and templates
    write_project_files(project_dir, name, runique_version)?;

    println!("  {}", tf("cli.project_created", &[&name]));
    println!("\n  {}", t("cli.getting_started"));
    println!("  {}", tf("cli.cd_hint", &[&name]));
    println!("  {}", t("cli.cargo_run_hint"));

    Ok(())
}

fn validate_project_name(name: &str) -> Result<()> {
    if name.is_empty() {
        anyhow::bail!("{}", t("cli.name_empty"));
    }
    if !name
        .chars()
        .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
    {
        anyhow::bail!("{}", t("cli.name_invalid"));
    }
    if name.starts_with('-') {
        anyhow::bail!("{}", t("cli.name_dash"));
    }
    Ok(())
}

fn create_project_dirs(project_dir: &Path) -> Result<()> {
    let dirs = [
        "src/entities",
        "src/formulaire",
        "static/css/inscription",
        "static/js",
        "media/favicon",
        "templates/about",
    ];
    for d in dirs {
        fs::create_dir_all(project_dir.join(d))?;
    }
    Ok(())
}

fn write_project_files(project_dir: &Path, name: &str, version: &str) -> Result<()> {
    // Files -> destination mapping
    let files: &[(&[u8], &str)] = &[
        (
            include_bytes!("../composant-bin/code/views.rs"),
            "src/views.rs",
        ),
        (
            include_bytes!("../composant-bin/code/forms.rs"),
            "src/formulaire/register.rs",
        ),
        (
            include_bytes!("../composant-bin/code/login_form.rs"),
            "src/formulaire/login.rs",
        ),
        (
            include_bytes!("../composant-bin/code/formulaire_mod.rs"),
            "src/formulaire/mod.rs",
        ),
        (include_bytes!("../composant-bin/code/url.rs"), "src/url.rs"),
        (
            include_bytes!("../composant-bin/code/main.rs"),
            "src/main.rs",
        ),
        (
            include_bytes!("../composant-bin/template/index.html"),
            "templates/index.html",
        ),
        (
            include_bytes!("../composant-bin/template/about.html"),
            "templates/about/about.html",
        ),
        (
            include_bytes!("../composant-bin/template/inscription_form.html"),
            "templates/inscription_form.html",
        ),
        (
            include_bytes!("../composant-bin/template/login_form.html"),
            "templates/login_form.html",
        ),
        (
            include_bytes!("../composant-bin/css/main.css"),
            "static/css/main.css",
        ),
        (
            include_bytes!("../composant-bin/css/about.css"),
            "static/css/about.css",
        ),
        (
            include_bytes!("../composant-bin/css/variables.css"),
            "static/css/variables.css",
        ),
        (
            include_bytes!("../composant-bin/css/inscription/inscription.css"),
            "static/css/inscription/inscription.css",
        ),
        (
            include_bytes!("../composant-bin/css/inscription/inscription-label.css"),
            "static/css/inscription/inscription-label.css",
        ),
        (
            include_bytes!("../composant-bin/image/toshiro.avif"),
            "media/toshiro.avif",
        ),
        (
            include_bytes!("../composant-bin/image/favicon.ico"),
            "media/favicon/favicon.ico",
        ),
        (
            include_bytes!("../composant-bin/config/ignore"),
            ".gitignore",
        ),
    ];

    for (content, path) in files {
        fs::write(project_dir.join(path), content)?;
    }
    fs::write(project_dir.join("src/entities/mod.rs"), "")?;
    // Cargo.toml, READMEs and .env with substitutions
    for (template, path) in [
        (
            include_str!("../composant-bin/config/apiconfig"),
            "Cargo.toml",
        ),
        (
            include_str!("../composant-bin/readme/README.md"),
            "README.md",
        ),
        (
            include_str!("../composant-bin/readme/README.fr.md"),
            "README.fr.md",
        ),
    ] {
        let content = template
            .replace("{{PROJECT_NAME}}", name)
            .replace("{{RUNIQUE_VERSION}}", version);
        fs::write(project_dir.join(path), content)?;
    }

    let secret_key: String = (0..32).map(|_| rand::rng().random::<u8>()).fold(
        String::with_capacity(64),
        |mut acc, b| {
            write!(&mut acc, "{b:02x}").unwrap();
            acc
        },
    );
    let env_file =
        include_str!("../composant-bin/config/secret").replace("your_secret_key_here", &secret_key);
    fs::write(project_dir.join(".env"), env_file)?;

    Ok(())
}

/// Written from cargo-mutants survivors (2026-10-02): the name becomes a
/// directory, so nothing that could leave the current one may pass.
#[cfg(test)]
mod name_tests {
    use super::validate_project_name;

    #[test]
    fn only_plain_names_are_accepted() {
        for ok in ["blog", "my_app", "my-app-2", "Été"] {
            assert!(validate_project_name(ok).is_ok(), "{ok}");
        }
        for bad in ["", "../escape", "a/b", "a b", "a.b", "-flag", "C:\\x"] {
            assert!(validate_project_name(bad).is_err(), "{bad:?}");
        }
    }
}

/// The generated project carries its name and runique's version, and no
/// placeholder is left in it (the README once said "v1.1.54" and "{}/").
#[cfg(test)]
mod generated_files_tests {
    use super::{create_project_dirs, write_project_files};

    #[test]
    fn no_placeholder_is_left_in_the_generated_files() {
        let dir = std::env::temp_dir().join(format!("rq_new_{}", uuid::Uuid::new_v4()));
        create_project_dirs(&dir).unwrap();
        write_project_files(&dir, "shop", "9.8.7").unwrap();
        for file in ["Cargo.toml", "README.md", "README.fr.md", ".env"] {
            let content = std::fs::read_to_string(dir.join(file)).unwrap();
            assert!(
                !content.contains("{{") && !content.contains("{}/"),
                "{file}"
            );
        }
        let readme = std::fs::read_to_string(dir.join("README.md")).unwrap();
        assert!(
            readme.contains("shop/") && readme.contains("v9.8.7"),
            "name and version"
        );
        assert!(
            readme.contains(r#"runique = { version = "9.8.7""#),
            "the migration crate depends on runique"
        );
        let _ = std::fs::remove_dir_all(&dir);
    }
}