arc-web-cli 0.5.1

Create self-contained Arc applications
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

const ARC_VERSION: &str = env!("CARGO_PKG_VERSION");

pub struct NewProject {
    pub name: String,
    pub destination: PathBuf,
    pub ui: bool,
    pub git: bool,
}

struct TemplateFile {
    path: &'static str,
    contents: &'static str,
    ui_only: bool,
}

const FILES: &[TemplateFile] = &[
    TemplateFile {
        path: "Cargo.toml",
        contents: include_str!("../templates/Cargo.toml.tpl"),
        ui_only: false,
    },
    TemplateFile {
        path: "Makefile",
        contents: include_str!("../templates/Makefile"),
        ui_only: false,
    },
    TemplateFile {
        path: ".env.example",
        contents: include_str!("../templates/env.example.tpl"),
        ui_only: false,
    },
    TemplateFile {
        path: ".gitignore",
        contents: include_str!("../templates/gitignore.tpl"),
        ui_only: false,
    },
    TemplateFile {
        path: "README.md",
        contents: include_str!("../templates/README.md.tpl"),
        ui_only: false,
    },
    TemplateFile {
        path: "src/main.rs",
        contents: include_str!("../templates/src/main.rs"),
        ui_only: false,
    },
    TemplateFile {
        path: "src/domain.rs",
        contents: include_str!("../templates/src/domain.rs"),
        ui_only: false,
    },
    TemplateFile {
        path: "src/routes.rs",
        contents: include_str!("../templates/src/routes.rs.tpl"),
        ui_only: false,
    },
    TemplateFile {
        path: "src/ui.rs",
        contents: include_str!("../templates/src/ui.rs"),
        ui_only: true,
    },
    TemplateFile {
        path: "resources/views/home.html",
        contents: include_str!("../templates/resources/views/home.html"),
        ui_only: true,
    },
    TemplateFile {
        path: "resources/views/layouts/public.html",
        contents: include_str!("../templates/resources/views/layouts/public.html"),
        ui_only: true,
    },
    TemplateFile {
        path: "resources/views/layouts/admin.html",
        contents: include_str!("../templates/resources/views/layouts/admin.html"),
        ui_only: true,
    },
    TemplateFile {
        path: "resources/views/components/ui.html",
        contents: include_str!("../templates/resources/views/components/ui.html"),
        ui_only: true,
    },
    TemplateFile {
        path: "resources/views/auth/signin.html",
        contents: include_str!("../templates/resources/views/auth/signin.html"),
        ui_only: true,
    },
    TemplateFile {
        path: "resources/views/auth/register.html",
        contents: include_str!("../templates/resources/views/auth/register.html"),
        ui_only: true,
    },
    TemplateFile {
        path: "resources/views/auth/forgot_password.html",
        contents: include_str!("../templates/resources/views/auth/forgot_password.html"),
        ui_only: true,
    },
    TemplateFile {
        path: "resources/views/auth/reset_password.html",
        contents: include_str!("../templates/resources/views/auth/reset_password.html"),
        ui_only: true,
    },
    TemplateFile {
        path: "resources/views/admin/dashboard.html",
        contents: include_str!("../templates/resources/views/admin/dashboard.html"),
        ui_only: true,
    },
    TemplateFile {
        path: "resources/views/admin/profile.html",
        contents: include_str!("../templates/resources/views/admin/profile.html"),
        ui_only: true,
    },
    TemplateFile {
        path: "resources/views/admin/settings.html",
        contents: include_str!("../templates/resources/views/admin/settings.html"),
        ui_only: true,
    },
    TemplateFile {
        path: "resources/views/errors/403.html",
        contents: include_str!("../templates/resources/views/errors/403.html"),
        ui_only: true,
    },
    TemplateFile {
        path: "resources/views/errors/404.html",
        contents: include_str!("../templates/resources/views/errors/404.html"),
        ui_only: true,
    },
    TemplateFile {
        path: "resources/views/errors/500.html",
        contents: include_str!("../templates/resources/views/errors/500.html"),
        ui_only: true,
    },
    TemplateFile {
        path: "public/styles.css",
        contents: include_str!("../templates/public/styles.css"),
        ui_only: true,
    },
    TemplateFile {
        path: "migrations/00000000000000_arc_base/up.sql",
        contents: include_str!("../templates/migrations/arc_base/up.sql"),
        ui_only: false,
    },
    TemplateFile {
        path: "migrations/00000000000000_arc_base/down.sql",
        contents: include_str!("../templates/migrations/arc_base/down.sql"),
        ui_only: false,
    },
];

pub fn create_project(project: &NewProject) -> Result<PathBuf, String> {
    validate_name(&project.name)?;
    let root = project.destination.join(&project.name);
    if root.exists() {
        return Err(format!(
            "destination `{}` already exists; Arc will not overwrite it",
            root.display()
        ));
    }

    fs::create_dir_all(&root)
        .map_err(|error| format!("could not create `{}`: {error}", root.display()))?;

    let result = write_project(project, &root);
    if result.is_err() {
        let _ = fs::remove_dir_all(&root);
    }
    result?;

    if project.git {
        initialize_git(&root)?;
    }

    Ok(root)
}

fn write_project(project: &NewProject, root: &Path) -> Result<(), String> {
    for file in FILES {
        if file.ui_only && !project.ui {
            continue;
        }
        let path = root.join(file.path);
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)
                .map_err(|error| format!("could not create `{}`: {error}", parent.display()))?;
        }
        let contents = render(file.contents, project);
        fs::write(&path, contents)
            .map_err(|error| format!("could not write `{}`: {error}", path.display()))?;
    }
    Ok(())
}

fn render(template: &str, project: &NewProject) -> String {
    let rendered = template
        .replace("{{project-name}}", &project.name)
        .replace("{{crate-name}}", &project.name.replace('-', "_"))
        .replace("{{arc-version}}", ARC_VERSION)
        .replace(
            "{{ui-module}}",
            if project.ui { "\nmod ui;" } else { "" },
        )
        .replace(
            "{{ui-routes}}",
            if project.ui {
                "\n        .configure(crate::ui::config)\n        .service(actix_files::Files::new(\"/public\", \"public\"))"
            } else {
                ""
            },
        );

    match std::env::var("ARC_CLI_TEST_LOCAL_ROOT") {
        Ok(root) => rendered
            .replace(
                &format!("arc-core = \"{ARC_VERSION}\""),
                &format!("arc-core = {{ path = \"{root}/crates/arc-core\" }}"),
            )
            .replace(
                &format!("arc-web = \"{ARC_VERSION}\""),
                &format!("arc-web = {{ path = \"{root}/crates/arc-web\" }}"),
            ),
        Err(_) => rendered,
    }
}

fn validate_name(name: &str) -> Result<(), String> {
    let mut chars = name.chars();
    let valid_first = chars
        .next()
        .is_some_and(|character| character.is_ascii_alphabetic());
    let valid_rest = chars.all(|character| character.is_ascii_alphanumeric() || character == '-');
    if !valid_first || !valid_rest {
        return Err(
            "project name must start with an ASCII letter and contain only letters, numbers, or hyphens"
                .to_string(),
        );
    }
    Ok(())
}

fn initialize_git(root: &Path) -> Result<(), String> {
    let status = Command::new("git")
        .args(["init", "--quiet"])
        .current_dir(root)
        .status()
        .map_err(|error| format!("could not run `git init`: {error}; retry with --no-git"))?;
    if !status.success() {
        return Err("`git init` failed; retry with --no-git".to_string());
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::{SystemTime, UNIX_EPOCH};

    fn temp_root() -> PathBuf {
        let nonce = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        std::env::temp_dir().join(format!("arc-cli-test-{}-{nonce}", std::process::id()))
    }

    #[test]
    fn creates_minimal_project_without_ui_files() {
        let destination = temp_root();
        let project = NewProject {
            name: "hello-arc".to_string(),
            destination: destination.clone(),
            ui: false,
            git: false,
        };
        let root = create_project(&project).unwrap();
        assert!(root.join("src/main.rs").is_file());
        assert!(root
            .join("migrations/00000000000000_arc_base/up.sql")
            .is_file());
        assert!(!root.join("src/ui.rs").exists());
        assert!(fs::read_to_string(root.join("Cargo.toml"))
            .unwrap()
            .contains(&format!("arc-web = \"{ARC_VERSION}\"")));
        fs::remove_dir_all(destination).unwrap();
    }

    #[test]
    fn ui_flag_adds_web_files_and_routes() {
        let destination = temp_root();
        let project = NewProject {
            name: "hello-ui".to_string(),
            destination: destination.clone(),
            ui: true,
            git: false,
        };
        let root = create_project(&project).unwrap();
        assert!(root.join("src/ui.rs").is_file());
        assert!(root.join("resources/views/home.html").is_file());
        assert!(root.join("resources/views/layouts/admin.html").is_file());
        assert!(root.join("resources/views/auth/signin.html").is_file());
        assert!(root.join("resources/views/errors/500.html").is_file());
        let routes = fs::read_to_string(root.join("src/routes.rs")).unwrap();
        assert!(routes.contains("crate::ui::config"));
        assert!(routes.contains("fn api_config(_cfg: &mut web::ServiceConfig)"));
        fs::remove_dir_all(destination).unwrap();
    }

    #[test]
    fn refuses_to_overwrite_existing_destination() {
        let destination = temp_root();
        fs::create_dir_all(destination.join("taken")).unwrap();
        let project = NewProject {
            name: "taken".to_string(),
            destination: destination.clone(),
            ui: false,
            git: false,
        };
        assert!(create_project(&project)
            .unwrap_err()
            .contains("will not overwrite"));
        fs::remove_dir_all(destination).unwrap();
    }
}