mecha10-templates 0.8.2

Mecha10 project templates — code generation, init, and template services
Documentation
//! Project template service for generating project files
//!
//! Coordinates template generation across the Rust/Infra/Meta template modules.

use crate::templates::{InfraTemplates, MetaTemplates, RlBundleTemplates, RustTemplates};
use anyhow::Result;
use std::path::Path;

macro_rules! delegate_create {
    ($(#[$meta:meta])* $name:ident($field:ident)) => {
        $(#[$meta])*
        pub async fn $name(&self, path: &Path) -> Result<()> {
            self.$field.$name(path).await
        }
    };
    ($(#[$meta:meta])* $name:ident($field:ident, project_name)) => {
        $(#[$meta])*
        pub async fn $name(&self, path: &Path, project_name: &str) -> Result<()> {
            self.$field.$name(path, project_name).await
        }
    };
    ($(#[$meta:meta])* $name:ident($field:ident, assets_root)) => {
        $(#[$meta])*
        pub async fn $name(&self, path: &Path, assets_root: &Path) -> Result<()> {
            self.$field.$name(path, assets_root).await
        }
    };
    ($(#[$meta:meta])* $name:ident($field:ident, project_name, assets_root)) => {
        $(#[$meta])*
        pub async fn $name(&self, path: &Path, project_name: &str, assets_root: &Path) -> Result<()> {
            self.$field.$name(path, project_name, assets_root).await
        }
    };
}

pub struct ProjectTemplateService {
    rust: RustTemplates,
    infra: InfraTemplates,
    meta: MetaTemplates,
    rl_bundle: RlBundleTemplates,
}

impl ProjectTemplateService {
    /// Create a new ProjectTemplateService
    pub fn new() -> Self {
        Self {
            rust: RustTemplates::new(),
            infra: InfraTemplates::new(),
            meta: MetaTemplates::new(),
            rl_bundle: RlBundleTemplates::new(),
        }
    }

    delegate_create!(
        /// Create README.md for the project, reading the template from the freshly-fetched
        /// `mecha10-assets` extraction at `assets_root`.
        create_readme(meta, project_name, assets_root)
    );

    delegate_create!(
        /// Create .gitignore for the project, reading the template from the freshly-fetched
        /// `mecha10-assets` extraction at `assets_root`.
        create_gitignore(meta, assets_root)
    );

    delegate_create!(
        /// Create Cargo.toml for the project, reading the template from the freshly-fetched
        /// `mecha10-assets` extraction at `assets_root`.
        create_cargo_toml(rust, project_name, assets_root)
    );

    delegate_create!(
        /// Create src/main.rs for the project, reading the template from the freshly-fetched
        /// `mecha10-assets` extraction at `assets_root`.
        create_main_rs(rust, project_name, assets_root)
    );

    delegate_create!(
        /// Create build.rs for the project, reading the template from the freshly-fetched
        /// `mecha10-assets` extraction at `assets_root`.
        ///
        /// Generates node_registry.rs, the node dispatcher based on mecha10.json.
        create_build_rs(rust, assets_root)
    );

    delegate_create!(
        /// Create .env.example for the project, reading the template from the freshly-fetched
        /// `mecha10-assets` extraction at `assets_root`.
        create_env_example(infra, project_name, assets_root)
    );

    /// Create rustfmt.toml for the project, reading the template from the freshly-fetched
    /// `mecha10-assets` extraction at `assets_root`.
    pub async fn create_rustfmt_toml(&self, path: &Path, assets_root: &Path) -> Result<()> {
        self.rust.create_rustfmt_toml(path, assets_root).await
    }

    delegate_create!(
        /// Create docker-compose.yml for the project with Redis and optional PostgreSQL
        /// services, reading the template from the freshly-fetched `mecha10-assets` extraction
        /// at `assets_root`.
        create_docker_compose(infra, project_name, assets_root)
    );

    delegate_create!(
        /// Create docker-compose.remote.yml and Dockerfile.remote for AI/ML nodes configured
        /// in targets.remote in mecha10.json, reading the templates from the freshly-fetched
        /// `mecha10-assets` extraction at `assets_root`.
        create_remote_docker_files(infra, assets_root)
    );

    delegate_create!(
        /// Create docker-compose.rl.yml, Dockerfile.rl, and entrypoint-rl.sh for running a
        /// trained RL policy in production, reading the templates from the freshly-fetched
        /// `mecha10-assets` extraction at `assets_root`.
        create_rl_docker_files(infra, assets_root)
    );

    delegate_create!(
        /// Create package.json for npm dependencies (requires npm install), reading the
        /// template from the freshly-fetched `mecha10-assets` extraction at `assets_root`.
        create_package_json(meta, project_name, assets_root)
    );

    delegate_create!(
        /// Create requirements.txt for Python dependencies needed for AI nodes, reading the
        /// template from the freshly-fetched `mecha10-assets` extraction at `assets_root`.
        create_requirements_txt(meta, assets_root)
    );

    delegate_create!(
        /// Create `.python-version`, pinning the Python version `mecha10 setup` should target,
        /// reading the template from the freshly-fetched `mecha10-assets` extraction at
        /// `assets_root`.
        create_python_version(meta, assets_root)
    );

    /// Create the standard RL training bundle for a `rover` template project, reading its job
    /// templates and Python requirements from the freshly-fetched `mecha10-assets` extraction at
    /// `assets_root`.
    ///
    /// Callers should invoke `create_requirements_txt` first — this appends to that file rather
    pub async fn create_rl_training_bundle(&self, path: &Path, assets_root: &Path) -> Result<()> {
        self.rl_bundle.append_rl_requirements(path, assets_root).await?;
        self.rl_bundle.create_rl_jobs(path, assets_root).await?;
        Ok(())
    }
}

impl Default for ProjectTemplateService {
    fn default() -> Self {
        Self::new()
    }
}

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

    /// Stage a fake extracted `mecha10-assets` root containing a single `project/<name>`
    /// template file, mirroring the real `mecha10-assets` layout.
    fn stage_assets_root_with(name: &str, content: &str) -> TempDir {
        let assets_root = TempDir::new().unwrap();
        let project_dir = assets_root.path().join("project");
        std::fs::create_dir_all(&project_dir).unwrap();
        std::fs::write(project_dir.join(name), content).unwrap();
        assets_root
    }

    #[tokio::test]
    async fn create_readme_replaces_project_name_placeholder() {
        let temp = TempDir::new().unwrap();
        let assets_root = stage_assets_root_with("README.md.template", "# {{project_name}}\n");
        let service = ProjectTemplateService::new();

        service
            .create_readme(temp.path(), "my-robot", assets_root.path())
            .await
            .unwrap();

        let content = tokio::fs::read_to_string(temp.path().join("README.md")).await.unwrap();
        assert!(content.contains("my-robot"));
        assert!(!content.contains("{{project_name}}"));
    }

    #[tokio::test]
    async fn create_gitignore_writes_file() {
        let temp = TempDir::new().unwrap();
        let assets_root = stage_assets_root_with(".gitignore.template", "target/\n");
        let service = ProjectTemplateService::new();

        service.create_gitignore(temp.path(), assets_root.path()).await.unwrap();

        assert!(temp.path().join(".gitignore").exists());
    }

    #[tokio::test]
    async fn create_python_version_writes_a_pin_mecha10_setup_can_read() {
        let temp = TempDir::new().unwrap();
        let assets_root = stage_assets_root_with(".python-version.template", "3.11\n");
        let service = ProjectTemplateService::new();

        service
            .create_python_version(temp.path(), assets_root.path())
            .await
            .unwrap();

        let content = tokio::fs::read_to_string(temp.path().join(".python-version"))
            .await
            .unwrap();
        let mut parts = content.trim().split('.');
        assert!(parts.next().unwrap().parse::<u32>().is_ok());
        assert!(parts.next().unwrap().parse::<u32>().is_ok());
        assert!(parts.next().is_none());
    }

    #[tokio::test]
    async fn create_cargo_toml_includes_project_name() {
        let temp = TempDir::new().unwrap();
        let assets_root =
            stage_assets_root_with("Cargo.toml.prod.template", "[package]\nname = \"{{project_name}}\"\n");
        let service = ProjectTemplateService::new();

        service
            .create_cargo_toml(temp.path(), "my-robot", assets_root.path())
            .await
            .unwrap();

        let content = tokio::fs::read_to_string(temp.path().join("Cargo.toml")).await.unwrap();
        assert!(content.contains("my-robot"));
    }

    #[tokio::test]
    async fn create_main_rs_writes_file() {
        let temp = TempDir::new().unwrap();
        let assets_root = stage_assets_root_with("main.rs.template", "fn main() {}\n");
        let service = ProjectTemplateService::new();

        service
            .create_main_rs(temp.path(), "my-robot", assets_root.path())
            .await
            .unwrap();

        assert!(temp.path().join("src/main.rs").exists());
    }

    #[tokio::test]
    async fn create_rl_training_bundle_requires_requirements_txt_created_first() {
        let temp = TempDir::new().unwrap();
        let assets_root = TempDir::new().unwrap();
        let jobs_dir = assets_root.path().join("rl/jobs");
        std::fs::create_dir_all(&jobs_dir).unwrap();
        std::fs::write(jobs_dir.join("train.yml.template"), "flow_type: train\n").unwrap();
        std::fs::write(
            jobs_dir.join("train-from-checkpoint.yml.template"),
            "flow_type: resume-run\n",
        )
        .unwrap();
        std::fs::write(jobs_dir.join("hypertune.yml.template"), "flow_type: tune\n").unwrap();
        std::fs::write(
            assets_root.path().join("rl/requirements-rl.txt.template"),
            "mecha10-rl-task-runner\n",
        )
        .unwrap();

        let service = ProjectTemplateService::new();

        service
            .create_rl_training_bundle(temp.path(), assets_root.path())
            .await
            .unwrap();

        assert!(temp.path().join("configs/rl/jobs/train.yml").exists());
        assert!(temp.path().join("requirements.txt").exists());
        assert!(!temp.path().join("kestra").exists());
    }
}