slave 0.2.0

Laravel-inspired CLI for Rust web apps: scaffold Axum projects with built-in auth, users, roles and Turso migrations
//! `slave make:*` generators for the domain-based layout.
//!
//! Each artifact gets its own file in a per-kind directory of its module,
//! `src/modules/<module>/{models,controllers}/<snake_name>.rs`, so a module can
//! hold several models and controllers. When no module is given it
//! is derived from the type name (`BlogPostController` → `blog_post`). Missing
//! modules are created and registered in `src/modules/mod.rs`.

use std::path::Path;

use anyhow::{Result, bail};

use crate::cli::MakeArgs;
use crate::{generator, naming, project, templates};

const MODULES_DIR: &str = "src/modules";

/// A kind of file that can be generated inside a domain module.
#[derive(Debug, Clone, Copy)]
pub enum Artifact {
    Model,
    Controller,
}

impl Artifact {
    fn label(self) -> &'static str {
        match self {
            Self::Model => "model",
            Self::Controller => "controller",
        }
    }

    /// Directory (and Rust module) for this kind of artifact inside a domain.
    fn directory(self) -> &'static str {
        match self {
            Self::Model => "models",
            Self::Controller => "controllers",
        }
    }

    fn template(self) -> &'static str {
        match self {
            Self::Model => templates::MODEL,
            Self::Controller => templates::CONTROLLER,
        }
    }

    /// The documented default domain for a type name.
    fn default_module(self, name: &str) -> Result<String> {
        let stem = match self {
            Self::Model => name,
            Self::Controller => name.strip_suffix("Controller").unwrap_or(name),
        };
        if stem.is_empty() {
            bail!("cannot derive a module from `{name}`; pass one with --module");
        }
        Ok(naming::to_snake_case(stem))
    }
}

pub fn run(artifact: Artifact, args: &MakeArgs) -> Result<()> {
    naming::validate_type_name(&args.name, artifact.label())?;
    let module = match &args.module {
        Some(module) => module.clone(),
        None => artifact.default_module(&args.name)?,
    };
    naming::validate_module_name(&module)?;

    let root = project::find_root()?;
    generate(&root, artifact, &args.name, &module, args.force)
}

fn generate(root: &Path, artifact: Artifact, name: &str, module: &str, force: bool) -> Result<()> {
    let modules_dir = root.join(MODULES_DIR);
    let modules_mod = modules_dir.join("mod.rs");
    if !modules_mod.is_file() {
        bail!(
            "{MODULES_DIR}/mod.rs not found; generators expect the domain-based layout created by `slave new`"
        );
    }

    let domain_dir = modules_dir.join(module);
    let domain_mod = domain_dir.join("mod.rs");
    let rel = |file: &str| format!("{MODULES_DIR}/{module}/{file}");

    // Write the requested file first: if it already exists nothing else is touched.
    let kind_dir = artifact.directory();
    let stem = naming::to_snake_case(name);
    let file_name = format!("{kind_dir}/{stem}.rs");
    let vars = [
        ("name", name),
        ("module", module),
        ("controller", &stem),
        ("route", &module.replace('_', "-")),
    ];
    let contents = templates::render(artifact.template(), &vars);
    generator::write_file(&domain_dir.join(&file_name), &contents, force)?;
    println!("  created {}", rel(&file_name));

    if !domain_mod.exists() {
        generator::write_file(&domain_mod, &format!("//! The `{module}` domain.\n"), false)?;
        println!("  created {}", rel("mod.rs"));
    }
    let kind_mod = domain_dir.join(kind_dir).join("mod.rs");
    generator::ensure_mod_decl(&kind_mod, &format!("pub mod {stem};"))?;
    generator::ensure_mod_decl(&domain_mod, &format!("mod {kind_dir};"))?;
    generator::ensure_mod_decl(&modules_mod, &format!("pub mod {module};"))?;

    if let Artifact::Controller = artifact {
        add_routes(&domain_dir, &modules_mod, module, &vars)?;
    }

    println!(
        "{} {name} [{}] created successfully.",
        capitalize(artifact.label()),
        rel(&file_name)
    );
    Ok(())
}

/// Gives a domain a `routes.rs` wired to its controller, unless it already has one.
fn add_routes(
    domain_dir: &Path,
    modules_mod: &Path,
    module: &str,
    vars: &[(&str, &str)],
) -> Result<()> {
    let routes = domain_dir.join("routes.rs");
    if routes.exists() {
        println!(
            "  note: {MODULES_DIR}/{module}/routes.rs already exists; register the new handlers there"
        );
        return Ok(());
    }

    generator::write_file(&routes, &templates::render(templates::ROUTES, vars), false)?;
    println!("  created {MODULES_DIR}/{module}/routes.rs");

    let domain_mod = domain_dir.join("mod.rs");
    generator::ensure_mod_decl(&domain_mod, "mod routes;")?;
    generator::ensure_trailing_line(&domain_mod, "pub use routes::routes;")?;

    let entry = format!("        {module}::routes(),");
    if !generator::insert_before_marker(modules_mod, templates::ROUTES_MARKER, &entry)? {
        println!(
            "  note: `{}` marker not found; add `{module}::routes()` to the list in {MODULES_DIR}/mod.rs",
            templates::ROUTES_MARKER
        );
    }
    Ok(())
}

fn capitalize(word: &str) -> String {
    let mut chars = word.chars();
    chars
        .next()
        .map(|first| first.to_ascii_uppercase().to_string() + chars.as_str())
        .unwrap_or_default()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_modules() {
        let model = |n| Artifact::Model.default_module(n).unwrap();
        let controller = |n| Artifact::Controller.default_module(n).unwrap();
        assert_eq!(model("User"), "user");
        assert_eq!(model("BlogPost"), "blog_post");
        assert_eq!(controller("UserController"), "user");
        assert_eq!(controller("BlogPostController"), "blog_post");
        assert_eq!(controller("Admin"), "admin");
        assert!(Artifact::Controller.default_module("Controller").is_err());
    }
}