slave 0.13.1

CLI for slave, a Laravel-inspired Rust framework for MCP-first apps: scaffold an MCP backend with auth, roles, teams and SurrealDB migrations, add server-rendered web apps, and route them by domain or path
//! Embedded file templates and a tiny `{{ placeholder }}` renderer.

/// Version requirement for `slave-framework` written into generated applications.
/// Keep in step with `framework/Cargo.toml`.
pub const FRAMEWORK_VERSION: &str = "0.13";

/// Version requirement for `slave-gateway` written into generated applications.
/// Keep in step with `gateway/Cargo.toml`.
pub const GATEWAY_VERSION: &str = "0.13";

/// A template file to be written relative to a target directory.
pub struct TemplateFile {
    /// Output path; may contain placeholders such as `{{ timestamp }}`.
    pub path: &'static str,
    pub contents: &'static str,
}

macro_rules! project_file {
    ($path:literal) => {
        project_file!($path, $path)
    };
    ($path:literal, $stub:literal) => {
        TemplateFile {
            path: $path,
            contents: include_str!(concat!("../templates/project/", $stub, ".stub")),
        }
    };
}

/// Files that make up a freshly generated application.
pub const PROJECT: &[TemplateFile] = &[
    project_file!("Cargo.toml"),
    project_file!(".env.example"),
    project_file!(".gitignore"),
    project_file!("README.md"),
    project_file!("AGENTS.md"),
    project_file!("CLAUDE.md"),
    project_file!("src/lib.rs"),
    project_file!("src/main.rs"),
    project_file!("src/app.rs"),
    project_file!("src/base/mod.rs"),
    project_file!("src/base/config.rs"),
    project_file!("src/base/error.rs"),
    project_file!("src/base/state.rs"),
    project_file!("src/base/auth.rs"),
    project_file!("src/base/mcp.rs"),
    project_file!("src/modules/mod.rs"),
    project_file!("src/modules/user/mod.rs"),
    project_file!("src/modules/user/models/mod.rs"),
    project_file!("src/modules/user/models/user.rs"),
    project_file!("src/modules/user/services/mod.rs"),
    project_file!("src/modules/user/services/user_service.rs"),
    project_file!("src/modules/user/tools.rs"),
    project_file!("src/modules/user/dto/mod.rs"),
    project_file!("src/modules/user/dto/user.rs"),
    project_file!("src/modules/role/mod.rs"),
    project_file!("src/modules/role/models/mod.rs"),
    project_file!("src/modules/role/models/role.rs"),
    project_file!("src/modules/role/services/mod.rs"),
    project_file!("src/modules/role/services/role_service.rs"),
    project_file!("src/modules/role/tools.rs"),
    project_file!("src/modules/role/dto/mod.rs"),
    project_file!("src/modules/role/dto/role.rs"),
    project_file!("src/modules/auth/mod.rs"),
    project_file!("src/modules/auth/services/mod.rs"),
    project_file!("src/modules/auth/services/auth_service.rs"),
    project_file!("src/modules/auth/tools.rs"),
    project_file!("src/modules/auth/dto/mod.rs"),
    project_file!("src/modules/auth/dto/auth.rs"),
    project_file!("src/modules/app/mod.rs"),
    project_file!("src/modules/app/models/mod.rs"),
    project_file!("src/modules/app/models/app.rs"),
    project_file!("src/modules/app/services/mod.rs"),
    project_file!("src/modules/app/services/app_service.rs"),
    project_file!("src/modules/app/tools.rs"),
    project_file!("src/modules/app/dto/mod.rs"),
    project_file!("src/modules/app/dto/app.rs"),
    project_file!("src/modules/team/mod.rs"),
    project_file!("src/modules/team/models/mod.rs"),
    project_file!("src/modules/team/models/team.rs"),
    project_file!("src/modules/team/models/membership.rs"),
    project_file!("src/modules/team/models/customer.rs"),
    project_file!("src/modules/team/models/invitation.rs"),
    project_file!("src/modules/team/services/mod.rs"),
    project_file!("src/modules/team/services/access.rs"),
    project_file!("src/modules/team/services/team_service.rs"),
    project_file!("src/modules/team/tools.rs"),
    project_file!("src/modules/team/dto/mod.rs"),
    project_file!("src/modules/team/dto/team.rs"),
    project_file!("src/bin/migrate.rs"),
    project_file!("runtime/Cargo.toml"),
    project_file!("runtime/src/main.rs"),
    project_file!("runtime/src/apps.rs"),
    project_file!("config/apps.toml"),
    project_file!("apps/README.md"),
    project_file!(
        "database/migrations/{{ migration_ts }}_create_users_and_teams.surql",
        "database/migrations/create_users_and_teams.surql"
    ),
    project_file!("tests/mcp.rs"),
];

macro_rules! app_file {
    ($stack:literal, $path:literal) => {
        TemplateFile {
            path: $path,
            contents: include_str!(concat!("../templates/apps/", $stack, "/", $path, ".stub")),
        }
    };
}

/// Files shared by every server-rendered app (`make:app --stack <engine>`),
/// relative to `apps/<name>`; the engine supplies `src/views.rs` and
/// `templates/`.
pub const THEME_APP: &[TemplateFile] = &[
    app_file!("theme", "Cargo.toml"),
    app_file!("theme", "src/lib.rs"),
    app_file!("theme", "src/main.rs"),
    app_file!("theme", "src/mcp.rs"),
    app_file!("theme", "src/pages.rs"),
    app_file!("theme", "src/sign_in.rs"),
];

/// A template engine for server-rendered apps.
pub struct Engine {
    /// Display name, e.g. `MiniJinja`.
    pub name: &'static str,
    /// The `[dependencies]` line for the app's Cargo.toml.
    pub dependency: &'static str,
    /// `src/views.rs` and `templates/`, copied as is: their `{{ … }}` belong
    /// to the engine, not to slave's placeholders.
    pub files: &'static [TemplateFile],
}

macro_rules! engine_file {
    ($engine:literal, $path:literal) => {
        TemplateFile {
            path: $path,
            contents: include_str!(concat!("../templates/apps/engines/", $engine, "/", $path)),
        }
    };
}

/// The admin app `slave new` creates, rendered with slave's placeholders: the
/// theme's server with its own manifest and pages.
pub const ADMIN_APP: &[TemplateFile] = &[
    TemplateFile {
        path: "Cargo.toml",
        contents: include_str!("../templates/apps/admin/Cargo.toml.stub"),
    },
    app_file!("theme", "src/lib.rs"),
    app_file!("theme", "src/main.rs"),
    app_file!("theme", "src/mcp.rs"),
    app_file!("theme", "src/sign_in.rs"),
    TemplateFile {
        path: "src/pages.rs",
        contents: include_str!("../templates/apps/admin/src/pages.rs.stub"),
    },
];

/// The admin app's views (MiniJinja), copied as is.
pub const ADMIN_RAW: &[TemplateFile] = &[
    engine_file!("minijinja", "src/views.rs"),
    TemplateFile {
        path: "templates/layout.html",
        contents: include_str!("../templates/apps/admin/templates/layout.html"),
    },
    TemplateFile {
        path: "templates/login.html",
        contents: include_str!("../templates/apps/admin/templates/login.html"),
    },
    TemplateFile {
        path: "templates/dashboard.html",
        contents: include_str!("../templates/apps/admin/templates/dashboard.html"),
    },
    TemplateFile {
        path: "templates/team.html",
        contents: include_str!("../templates/apps/admin/templates/team.html"),
    },
    TemplateFile {
        path: "templates/message.html",
        contents: include_str!("../templates/apps/admin/templates/message.html"),
    },
];

pub const MINIJINJA: Engine = Engine {
    name: "MiniJinja",
    dependency: "minijinja = { version = \"3\", features = [\"serde\"] }",
    files: &[
        engine_file!("minijinja", "src/views.rs"),
        engine_file!("minijinja", "templates/layout.html"),
        engine_file!("minijinja", "templates/home.html"),
        engine_file!("minijinja", "templates/login.html"),
    ],
};

pub const TERA: Engine = Engine {
    name: "Tera",
    dependency: "tera = \"2\"",
    files: &[
        engine_file!("tera", "src/views.rs"),
        engine_file!("tera", "templates/layout.html"),
        engine_file!("tera", "templates/home.html"),
        engine_file!("tera", "templates/login.html"),
    ],
};

pub const HANDLEBARS: Engine = Engine {
    name: "Handlebars",
    dependency: "handlebars = \"6\"",
    files: &[
        engine_file!("handlebars", "src/views.rs"),
        engine_file!("handlebars", "templates/layout.hbs"),
        engine_file!("handlebars", "templates/home.hbs"),
        engine_file!("handlebars", "templates/login.hbs"),
    ],
};

pub const LIQUID: Engine = Engine {
    name: "Liquid",
    dependency: "liquid = \"0.26\"",
    files: &[
        engine_file!("liquid", "src/views.rs"),
        engine_file!("liquid", "templates/layout_top.liquid"),
        engine_file!("liquid", "templates/layout_bottom.liquid"),
        engine_file!("liquid", "templates/home.liquid"),
        engine_file!("liquid", "templates/login.liquid"),
    ],
};

#[cfg(test)]
pub const ENGINES: [&Engine; 4] = [&MINIJINJA, &TERA, &HANDLEBARS, &LIQUID];

/// Files written by `slave install:fly`, relative to the project root.
pub const FLY: &[TemplateFile] = &[
    TemplateFile {
        path: "Dockerfile",
        contents: include_str!("../templates/fly/Dockerfile.stub"),
    },
    TemplateFile {
        path: ".dockerignore",
        contents: include_str!("../templates/fly/dockerignore.stub"),
    },
    TemplateFile {
        path: "fly.toml",
        contents: include_str!("../templates/fly/fly.toml.stub"),
    },
];

pub const MODEL: &str = include_str!("../templates/make/model.rs.stub");
pub const MIGRATION: &str = include_str!("../templates/make/migration.surql.stub");
pub const MODEL_MIGRATION: &str = include_str!("../templates/make/model_migration.surql.stub");
pub const TOOLS: &str = include_str!("../templates/make/tools.rs.stub");

/// Marker line in `runtime/src/apps.rs` above which apps are mounted.
pub const APPS_MARKER: &str = "// slave:apps";

/// Marker line in `src/modules/mod.rs` above which domain MCP tools are registered.
pub const TOOLS_MARKER: &str = "// slave:tools";

/// Replaces every `{{ key }}` in `template` with its value from `vars`.
pub fn render(template: &str, vars: &[(&str, &str)]) -> String {
    vars.iter().fold(template.to_owned(), |acc, (key, value)| {
        acc.replace(&format!("{{{{ {key} }}}}"), value)
    })
}

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

    #[test]
    fn renders_placeholders() {
        let out = render(
            "struct {{ name }}; // {{ name }} {{ other }}",
            &[("name", "User")],
        );
        assert_eq!(out, "struct User; // User {{ other }}");
    }

    #[test]
    fn templates_have_no_unknown_placeholders() {
        let project_vars = [
            ("name", "demo"),
            ("slave_version", "0.0.0"),
            ("framework_dependency", "slave-framework = \"0.1\""),
            ("gateway_dependency", "slave-gateway = \"0.1\""),
            ("crate_name", "demo"),
            ("migration_ts", "20260101_000000"),
        ];
        for file in PROJECT {
            let path = render(file.path, &project_vars);
            assert!(!path.contains("{{ "), "{path}");
            assert!(
                !render(file.contents, &project_vars).contains("{{ "),
                "{path}"
            );
        }

        let make_vars = [
            ("name", "Post"),
            ("module", "post"),
            ("prefix", "post"),
            ("table", "post"),
        ];
        for template in [MODEL, TOOLS, MIGRATION, MODEL_MIGRATION] {
            assert!(!render(template, &make_vars).contains("{{ "));
        }

        let app_vars = [
            ("app", "dashboard"),
            ("crate_name", "dashboard"),
            ("title", "Dashboard"),
            ("port", "4000"),
            ("env", "DASHBOARD"),
            ("engine_dependency", MINIJINJA.dependency),
            ("engine_name", MINIJINJA.name),
            ("customer", "false"),
        ];
        for file in THEME_APP.iter().chain(ADMIN_APP) {
            assert!(
                !render(file.contents, &app_vars).contains("{{ "),
                "{}",
                file.path
            );
        }
    }

    #[test]
    fn every_engine_has_views_and_the_pages_rendered_by_pages_rs() {
        for engine in ENGINES {
            let paths: Vec<&str> = engine.files.iter().map(|f| f.path).collect();
            assert!(paths.contains(&"src/views.rs"), "{}", engine.name);
            for view in ["home", "login"] {
                assert!(
                    paths
                        .iter()
                        .any(|p| p.starts_with(&format!("templates/{view}."))),
                    "{} lacks the `{view}` view",
                    engine.name
                );
            }
            let views = engine.files[0].contents;
            assert!(views.contains("pub fn load(dir: &Path)"), "{}", engine.name);
            assert!(views.contains("pub fn render("), "{}", engine.name);
        }
        // Liquid does not escape by itself: every output must use `| escape`.
        for file in LIQUID.files.iter().filter(|f| f.path.ends_with(".liquid")) {
            for output in file.contents.split("{{").skip(1) {
                let expression = output.split("}}").next().unwrap_or_default();
                assert!(
                    expression.contains("| escape"),
                    "{}: {{{{{expression}}}}}",
                    file.path
                );
            }
        }
    }

    #[test]
    fn modules_template_has_tools_marker() {
        let modules = PROJECT
            .iter()
            .find(|file| file.path == "src/modules/mod.rs")
            .expect("modules template");
        assert!(modules.contents.contains(TOOLS_MARKER));
    }

    #[test]
    fn versions_match_workspace_crates() {
        // These manifests only exist in the workspace, not the published package.
        for (dir, version) in [
            ("framework", FRAMEWORK_VERSION),
            ("gateway", GATEWAY_VERSION),
        ] {
            let manifest = format!("{}/{dir}/Cargo.toml", env!("CARGO_MANIFEST_DIR"));
            let Ok(contents) = std::fs::read_to_string(manifest) else {
                continue;
            };
            let expected = format!("version = \"{version}.");
            assert!(
                contents.contains(&expected),
                "update the {dir} version constant"
            );
        }
    }
}