slave 0.8.1

CLI for slave, a Laravel-inspired Rust framework for MCP-first apps: scaffold an MCP backend with auth, roles and Toasty models and migrations, add server-rendered web apps, and route them by domain or path
//! `slave make:*` generators for the domain-based layout.
//!
//! Models get their own file in `src/modules/<module>/models/<snake_name>.rs`,
//! so a module can hold several. MCP tools live in one file per module,
//! `src/modules/<module>/tools.rs`, registered in `src/modules/mod.rs`; tools
//! are the only way business operations are exposed (there are no HTTP
//! routes). When no module is given it is derived from the type name
//! (`BlogPostTools` → `blog_post`). Missing modules are created.

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,
    Tools,
}

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

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

    /// The type name without its artifact suffix (`PostTools` → `Post`).
    fn stem(self, name: &str) -> &str {
        match self {
            Self::Model => name,
            Self::Tools => name.strip_suffix("Tools").unwrap_or(name),
        }
    }

    /// The documented default domain for a type name.
    fn default_module(self, name: &str) -> Result<String> {
        let stem = self.stem(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 (file_name, vars) = match artifact {
        Artifact::Model => {
            let stem = naming::to_snake_case(name);
            (format!("models/{stem}.rs"), vec![("name", name.to_owned())])
        }
        Artifact::Tools => {
            let stem = artifact.stem(name);
            if stem.is_empty() {
                bail!("`{name}` needs a name before `Tools`, e.g. `PostTools`");
            }
            let vars = vec![
                ("name", stem.to_owned()),
                ("prefix", naming::to_snake_case(stem)),
            ];
            ("tools.rs".to_owned(), vars)
        }
    };
    let mut vars: Vec<(&str, &str)> = vars.iter().map(|(k, v)| (*k, v.as_str())).collect();
    vars.push(("module", module));
    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"));
    }
    match artifact {
        Artifact::Model => {
            let stem = naming::to_snake_case(name);
            generator::ensure_mod_decl(
                &domain_dir.join("models/mod.rs"),
                &format!("pub mod {stem};"),
            )?;
            generator::ensure_mod_decl(&domain_mod, "mod models;")?;
            generator::ensure_mod_decl(&modules_mod, &format!("pub mod {module};"))?;
        }
        Artifact::Tools => {
            generator::ensure_mod_decl(&domain_mod, "mod tools;")?;
            generator::ensure_trailing_line(&domain_mod, "pub use tools::tools;")?;
            generator::ensure_mod_decl(&modules_mod, &format!("pub mod {module};"))?;
            register_tools(&modules_mod, module)?;
        }
    }

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

/// Adds `<module>::tools()` to the tool registry in `src/modules/mod.rs`.
fn register_tools(modules_mod: &Path, module: &str) -> Result<()> {
    let entry = format!("        {module}::tools(),");
    if std::fs::read_to_string(modules_mod)?.contains(entry.trim()) {
        return Ok(());
    }
    if !generator::insert_before_marker(modules_mod, templates::TOOLS_MARKER, &entry)? {
        println!(
            "  note: `{}` marker not found; add `{module}::tools()` to the list in {MODULES_DIR}/mod.rs",
            templates::TOOLS_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 tools = |n| Artifact::Tools.default_module(n).unwrap();
        assert_eq!(model("User"), "user");
        assert_eq!(model("BlogPost"), "blog_post");
        assert_eq!(tools("UserTools"), "user");
        assert_eq!(tools("BlogPostTools"), "blog_post");
        assert_eq!(tools("Admin"), "admin");
        assert!(Artifact::Tools.default_module("Tools").is_err());
    }
}