xecute-cli 0.1.1

NestJS-style project and resource generator for Xecute
use std::{
    fs,
    path::{Path, PathBuf},
    process::Command,
};

type Result<T> = std::result::Result<T, String>;

fn names(input: &str) -> Result<(String, String)> {
    if input.is_empty()
        || !input
            .bytes()
            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-' || c == b'_')
        || !input.as_bytes()[0].is_ascii_lowercase()
    {
        return Err(
            "use a lowercase name starting with a letter, such as user or user-profile".into(),
        );
    }
    let snake = input.replace('-', "_");
    if snake.split('_').any(str::is_empty)
        || matches!(
            snake.as_str(),
            "self"
                | "super"
                | "crate"
                | "mod"
                | "type"
                | "fn"
                | "pub"
                | "use"
                | "impl"
                | "struct"
                | "enum"
                | "trait"
                | "async"
                | "await"
                | "loop"
                | "match"
                | "move"
                | "ref"
                | "let"
                | "const"
                | "static"
                | "where"
                | "in"
                | "for"
                | "if"
                | "else"
                | "return"
                | "break"
                | "continue"
                | "true"
                | "false"
                | "unsafe"
                | "extern"
                | "dyn"
                | "as"
                | "mut"
                | "while"
                | "union"
                | "abstract"
                | "become"
                | "box"
                | "do"
                | "final"
                | "macro"
                | "override"
                | "priv"
                | "typeof"
                | "unsized"
                | "virtual"
                | "yield"
                | "try"
                | "gen"
        )
    {
        return Err("name must be a valid non-keyword Rust module name".into());
    }
    let pascal = snake
        .split('_')
        .map(|s| format!("{}{}", s[..1].to_uppercase(), &s[1..]))
        .collect();
    Ok((snake, pascal))
}

fn write(path: &Path, content: &str) -> Result<()> {
    fs::write(path, content).map_err(|e| format!("{}: {e}", path.display()))
}

fn format_files(root: &Path, paths: &[String]) -> Result<()> {
    let status = Command::new("rustfmt")
        .current_dir(root)
        .args(["--edition", "2024", "--config", "skip_children=true"])
        .args(paths)
        .status()
        .map_err(|error| {
            format!("files generated but formatting failed: {error}; run cargo fmt")
        })?;
    if !status.success() {
        return Err("files generated but formatting failed; run cargo fmt".into());
    }
    Ok(())
}

pub fn new_project(name: &str, runtime: Option<&Path>) -> Result<()> {
    names(name)?;
    let root = PathBuf::from(name);
    if root.exists() {
        return Err(format!("{} already exists", root.display()));
    }
    let dependency = if let Some(runtime) = runtime {
        let runtime = runtime
            .canonicalize()
            .map_err(|e| format!("runtime path: {e}; supply --runtime-path"))?;
        if !runtime.join("Cargo.toml").is_file() {
            return Err("runtime path must contain xecute/Cargo.toml".into());
        }
        let runtime = runtime
            .to_str()
            .ok_or("runtime path must be UTF-8")?
            .replace('\\', "\\\\")
            .replace('"', "\\\"");
        format!("xecute = {{ path = \"{runtime}\" }}")
    } else {
        "xecute = \"0.1.0\"".to_owned()
    };
    fs::create_dir_all(root.join("src")).map_err(|e| e.to_string())?;
    write(
        &root.join("Cargo.toml"),
        &include_str!("../templates/Cargo.toml.tmpl")
            .replace("__PROJECT_NAME__", name)
            .replace("__RUNTIME_DEPENDENCY__", &dependency),
    )?;
    write(&root.join(".gitignore"), "/target/\n.env\n")?;
    let mut formatted = Vec::new();
    for (path, template) in [
        ("src/main.rs", include_str!("../templates/main.rs")),
        ("src/config.rs", include_str!("../templates/config.rs")),
        ("src/logging.rs", include_str!("../templates/logging.rs")),
        ("src/error.rs", include_str!("../templates/error.rs")),
        (
            "src/rate_limit.rs",
            include_str!("../templates/rate_limit.rs"),
        ),
        ("src/server.rs", include_str!("../templates/server.rs")),
        (
            "src/server/tests.rs",
            include_str!("../templates/server_tests.rs"),
        ),
        (".env.example", include_str!("../templates/env.example")),
        (
            "rust-toolchain.toml",
            include_str!("../templates/rust-toolchain.toml"),
        ),
        ("rustfmt.toml", include_str!("../templates/rustfmt.toml")),
        ("clippy.toml", include_str!("../templates/clippy.toml")),
        (".editorconfig", include_str!("../templates/editorconfig")),
        (".dockerignore", include_str!("../templates/dockerignore")),
        ("Dockerfile", include_str!("../templates/Dockerfile")),
        ("compose.yaml", include_str!("../templates/compose.yaml")),
        (
            ".github/workflows/ci.yml",
            include_str!("../templates/ci.yml"),
        ),
        ("deny.toml", include_str!("../templates/deny.toml")),
        (".gitattributes", include_str!("../templates/gitattributes")),
        (
            ".github/dependabot.yml",
            include_str!("../templates/dependabot.yml"),
        ),
    ] {
        if path.ends_with(".rs") {
            formatted.push(path.to_owned());
        }
        let path = root.join(path);
        fs::create_dir_all(path.parent().expect("template parent")).map_err(|e| e.to_string())?;
        write(&path, &template.replace("__PROJECT_NAME__", name))?;
    }
    write(
        &root.join("src/app_module.rs"),
        include_str!("../templates/app_module.rs"),
    )?;
    write(
        &root.join("README.md"),
        &include_str!("../templates/readme.md").replace("__PROJECT_NAME__", name),
    )?;
    if runtime.is_none() {
        write(
            &root.join("Cargo.lock"),
            &include_str!("../templates/project.lock").replace("__PROJECT_NAME__", name),
        )?;
    }
    formatted.push("src/app_module.rs".into());
    format_files(&root, &formatted)?;
    println!("Created {name}\n  cd {name}\n  xecute g resource user\n  cargo run");
    Ok(())
}

pub fn component(root: &Path, kind: &str, name: &str) -> Result<()> {
    let kind = match kind {
        "resource" | "res" => "resource",
        "module" | "mo" => "module",
        "controller" | "co" => "controller",
        "service" | "s" => "service",
        _ => return Err("generator must be resource, module, controller, or service".into()),
    };
    let (snake, pascal) = names(name)?;
    if matches!(snake.as_str(), "app_module" | "main") {
        return Err("name conflicts with application files".into());
    }
    let main_path = root.join("src/main.rs");
    let app_path = root.join("src/app_module.rs");
    let mut main =
        fs::read_to_string(&main_path).map_err(|_| "run generate from a Xecute project root")?;
    let mut app = fs::read_to_string(&app_path).map_err(|_| "missing src/app_module.rs")?;
    let folder = root.join("src").join(&snake);
    if folder.exists() {
        return Err(format!(
            "{} already exists; no files changed",
            folder.display()
        ));
    }
    let markers = [
        "// xecute:modules",
        "// xecute:imports",
        "// xecute:definitions",
        "// xecute:routes",
    ];
    if markers.iter().any(|m| app.matches(m).count() != 1) {
        return Err(
            "app_module.rs must have the registration markers from xecute new; no files changed"
                .into(),
        );
    }
    if main.contains(&format!("mod {snake};")) {
        return Err("module already declared; no files changed".into());
    }
    let service = format!(
        "#[xecute::injectable]\npub struct {pascal}Service {{}}\n\nimpl {pascal}Service {{\n    pub fn list(&self) -> Vec<String> {{ Vec::new() }}\n}}\n"
    );
    let controller = if kind == "resource" {
        format!(
            "use std::sync::Arc;\nuse axum::{{extract::State, Json}};\nuse super::service::{pascal}Service;\n\n#[xecute::injectable]\npub struct {pascal}Controller {{ service: Arc<{pascal}Service> }}\n\n#[xecute::controller(\"/{snake}\")]\nimpl {pascal}Controller {{\n    #[get(\"\")]\n    async fn list(State(controller): State<Arc<Self>>) -> Json<Vec<String>> {{\n        Json(controller.service.list())\n    }}\n}}\n"
        )
    } else {
        format!(
            "use axum::Json;\n\n#[xecute::injectable]\npub struct {pascal}Controller {{}}\n\n#[xecute::controller(\"/{snake}\")]\nimpl {pascal}Controller {{\n    #[get(\"\")]\n    async fn list() -> Json<Vec<String>> {{ Json(Vec::new()) }}\n}}\n"
        )
    };
    let mut files: Vec<(String, String)> = Vec::new();
    let mut mods = String::new();
    if matches!(kind, "resource" | "service") {
        files.push(("service.rs".into(), service));
        mods.push_str(&format!(
            "mod service;\npub use service::{pascal}Service;\n"
        ));
    }
    if matches!(kind, "resource" | "controller") {
        files.push(("controller.rs".into(), controller));
        mods.push_str(&format!(
            "mod controller;\npub use controller::{pascal}Controller;\n"
        ));
    }
    if matches!(kind, "resource" | "module") {
        let module = if kind == "resource" {
            format!(
                "use super::{{{pascal}Controller, {pascal}Service}};\n\n#[xecute::module(providers = [{pascal}Service, {pascal}Controller], controllers = [{pascal}Controller], exports = [{pascal}Service])]\npub struct {pascal}Module;\n"
            )
        } else {
            format!("#[xecute::module(providers = [])]\npub struct {pascal}Module;\n")
        };
        files.push((format!("{snake}_module.rs"), module));
        mods.push_str(&format!(
            "mod {snake}_module;\npub use {snake}_module::{pascal}Module;\n"
        ));
        app = app
            .replace(
                markers[0],
                &format!("use crate::{snake}::{pascal}Module;\n{}", markers[0]),
            )
            .replace(markers[1], &format!("{pascal}Module,\n    {}", markers[1]))
            .replace(
                markers[2],
                &format!("{pascal}Module::definition(),\n            {}", markers[2]),
            );
        if kind == "resource" {
            app = app.replace(markers[3], &format!("let router = router.merge(crate::{snake}::{pascal}Controller::router(container.resolve::<crate::{snake}::{pascal}Controller>()?));\n        {}", markers[3]));
        }
    }
    files.push(("mod.rs".into(), mods));
    main = format!("pub mod {snake};\n{main}");
    fs::create_dir(&folder).map_err(|e| e.to_string())?;
    let mut formatted = vec!["src/main.rs".into(), "src/app_module.rs".into()];
    for (name, content) in files {
        formatted.push(format!("src/{snake}/{name}"));
        write(&folder.join(name), &content)?;
    }
    write(&main_path, &main)?;
    write(&app_path, &app)?;
    format_files(root, &formatted)?;
    println!("Created {kind} {pascal} in src/{snake}");
    if matches!(kind, "service" | "controller") {
        println!("Add this building block to a module's providers when ready to use it.");
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn names_reject_paths_and_keywords() {
        for name in ["../user", "user/name", "mod", "user__name", "1user"] {
            assert!(names(name).is_err());
        }
        assert_eq!(
            names("user-profile").unwrap(),
            ("user_profile".into(), "UserProfile".into())
        );
    }
    #[test]
    fn resource_registers_and_duplicate_preserves_files() {
        let root = std::env::temp_dir().join(format!("xecute-cli-test-{}", std::process::id()));
        fs::create_dir_all(root.join("src")).unwrap();
        write(&root.join("src/main.rs"), "mod app_module;\n").unwrap();
        write(
            &root.join("src/app_module.rs"),
            include_str!("../templates/app_module.rs"),
        )
        .unwrap();
        component(&root, "res", "user").unwrap();
        let before = fs::read_to_string(root.join("src/app_module.rs")).unwrap();
        assert!(before.contains("UserController::router"));
        assert!(component(&root, "res", "user").is_err());
        assert_eq!(
            before,
            fs::read_to_string(root.join("src/app_module.rs")).unwrap()
        );
        fs::remove_dir_all(root).unwrap();
    }
}