Skip to main content

mecha10_templates/services/
project_template_service.rs

1//! Project template service for generating project files
2//!
3//! This service coordinates template generation across specialized modules:
4//! - Rust templates (Cargo.toml, main.rs, build.rs)
5//! - Infrastructure templates (docker-compose.yml, .env)
6//! - Meta templates (README.md, .gitignore, package.json)
7//!
8//! # Architecture
9//!
10//! Templates are organized into focused modules under `crate::templates`:
11//! - **RustTemplates**: Rust-specific files
12//! - **InfraTemplates**: Infrastructure files
13//! - **MetaTemplates**: Project metadata files
14//!
15//! Template content is stored in external files at `packages/mecha10-templates/templates/`
16//! and embedded at compile time using `include_str!()`.
17
18use crate::templates::{InfraTemplates, MetaTemplates, RlBundleTemplates, RustTemplates};
19use anyhow::Result;
20use std::path::Path;
21
22macro_rules! delegate_create {
23    ($(#[$meta:meta])* $name:ident($field:ident)) => {
24        $(#[$meta])*
25        pub async fn $name(&self, path: &Path) -> Result<()> {
26            self.$field.$name(path).await
27        }
28    };
29    ($(#[$meta:meta])* $name:ident($field:ident, project_name)) => {
30        $(#[$meta])*
31        pub async fn $name(&self, path: &Path, project_name: &str) -> Result<()> {
32            self.$field.$name(path, project_name).await
33        }
34    };
35}
36
37pub struct ProjectTemplateService {
38    rust: RustTemplates,
39    infra: InfraTemplates,
40    meta: MetaTemplates,
41    rl_bundle: RlBundleTemplates,
42}
43
44impl ProjectTemplateService {
45    /// Create a new ProjectTemplateService
46    pub fn new() -> Self {
47        Self {
48            rust: RustTemplates::new(),
49            infra: InfraTemplates::new(),
50            meta: MetaTemplates::new(),
51            rl_bundle: RlBundleTemplates::new(),
52        }
53    }
54
55    delegate_create!(
56        /// Create README.md for the project
57        create_readme(meta, project_name)
58    );
59
60    delegate_create!(
61        /// Create .gitignore for the project
62        create_gitignore(meta)
63    );
64
65    delegate_create!(
66        /// Create Cargo.toml for the project
67        ///
68        /// # Arguments
69        ///
70        /// * `path` - Project root path
71        /// * `project_name` - Name of the project
72        create_cargo_toml(rust, project_name)
73    );
74
75    delegate_create!(
76        /// Create src/main.rs for the project
77        create_main_rs(rust, project_name)
78    );
79
80    delegate_create!(
81        /// Create build.rs for the project
82        ///
83        /// This build script generates:
84        /// 1. node_registry.rs - Node dispatcher based on mecha10.json
85        create_build_rs(rust)
86    );
87
88    delegate_create!(
89        /// Create .env.example for the project
90        ///
91        /// # Arguments
92        ///
93        /// * `path` - Project root path
94        /// * `project_name` - Name of the project
95        create_env_example(infra, project_name)
96    );
97
98    delegate_create!(
99        /// Create rustfmt.toml for the project
100        create_rustfmt_toml(rust)
101    );
102
103    delegate_create!(
104        /// Create docker-compose.yml for the project
105        ///
106        /// Generates a Docker Compose file with Redis and optional PostgreSQL services
107        /// for local development and deployment.
108        ///
109        /// # Arguments
110        ///
111        /// * `path` - Project root path
112        /// * `project_name` - Name of the project (used for container naming)
113        create_docker_compose(infra, project_name)
114    );
115
116    delegate_create!(
117        /// Create remote node Docker files
118        ///
119        /// Creates docker-compose.remote.yml and Dockerfile.remote for running
120        /// AI/ML nodes in containers. These are used when nodes are configured
121        /// in targets.remote in mecha10.json.
122        ///
123        /// # Arguments
124        ///
125        /// * `path` - Project root path
126        create_remote_docker_files(infra)
127    );
128
129    delegate_create!(
130        /// Create rl target Docker files
131        ///
132        /// Creates docker-compose.rl.yml, Dockerfile.rl, and entrypoint-rl.sh for running a
133        /// trained RL policy in production. Modeled on `create_remote_docker_files`, but
134        /// deliberately excludes the simulator - see that method's doc comment.
135        ///
136        /// # Arguments
137        ///
138        /// * `path` - Project root path
139        create_rl_docker_files(infra)
140    );
141
142    delegate_create!(
143        /// Create package.json for npm dependencies
144        ///
145        /// Includes @mecha10/simulation-models and @mecha10/simulation-environments dependencies,
146        /// resolved via node_modules/@mecha10/* (requires npm install).
147        ///
148        /// # Arguments
149        ///
150        /// * `path` - Project root path
151        /// * `project_name` - Name of the project
152        create_package_json(meta, project_name)
153    );
154
155    delegate_create!(
156        /// Create requirements.txt for Python dependencies
157        ///
158        /// Includes Python packages needed for AI nodes (onnx, onnxruntime).
159        /// Install with: `mecha10 setup` or `pip install -r requirements.txt`
160        ///
161        /// # Arguments
162        ///
163        /// * `path` - Project root path
164        create_requirements_txt(meta)
165    );
166
167    delegate_create!(
168        /// Create `.python-version`, pinning the exact Python version `mecha10 setup`
169        /// should target for the RL/simulation venv.
170        ///
171        /// # Arguments
172        ///
173        /// * `path` - Project root path
174        create_python_version(meta)
175    );
176
177    /// Create the standard RL training bundle (LAB-1743) for a `rover` template project
178    ///
179    /// Bundles the Phase 2 `rover` env / `ppo` algorithm (`packages/rl-trainer`,
180    /// `packages/rl-task-runner`, registered via the `mecha10_rl.envs`/`mecha10_rl.algorithms`
181    /// entry-points registry, LAB-1732/1733) into two pieces:
182    ///
183    /// - `configs/rl/jobs/` - the standard job catalog; each job file is a complete training
184    ///   config (job identity plus every `PPOConfig` hyperparameter, LAB-2970) - there's no more
185    ///   separate `configs/rl/local.yml` sentinel-override config to layer onto.
186    /// - `requirements.txt` - appended with the two packages' dependencies, since both already
187    ///   register their entry points in their own `pyproject.toml` (nothing else to declare).
188    ///
189    /// A project no longer gets its own `kestra/` directory (LAB-2428) - a robot developer
190    /// shouldn't need to know Kestra is involved; the canonical `train`/`tune`/`tune-then-train`/
191    /// `train-from-tune`/`resume-run` flows live solely in `mecha10-rl`, deployed via its own CI.
192    ///
193    /// Callers should invoke `create_requirements_txt` first — this appends to that file rather
194    /// than creating it.
195    ///
196    /// # Arguments
197    ///
198    /// * `path` - Project root path
199    pub async fn create_rl_training_bundle(&self, path: &Path) -> Result<()> {
200        self.rl_bundle.append_rl_requirements(path).await?;
201        self.rl_bundle.create_rl_jobs(path).await?;
202        Ok(())
203    }
204}
205
206impl Default for ProjectTemplateService {
207    fn default() -> Self {
208        Self::new()
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use tempfile::TempDir;
216
217    #[tokio::test]
218    async fn create_readme_replaces_project_name_placeholder() {
219        let temp = TempDir::new().unwrap();
220        let service = ProjectTemplateService::new();
221
222        service.create_readme(temp.path(), "my-robot").await.unwrap();
223
224        let content = tokio::fs::read_to_string(temp.path().join("README.md")).await.unwrap();
225        assert!(content.contains("my-robot"));
226        assert!(!content.contains("{{project_name}}"));
227    }
228
229    #[tokio::test]
230    async fn create_gitignore_writes_file() {
231        let temp = TempDir::new().unwrap();
232        let service = ProjectTemplateService::new();
233
234        service.create_gitignore(temp.path()).await.unwrap();
235
236        assert!(temp.path().join(".gitignore").exists());
237    }
238
239    #[tokio::test]
240    async fn create_python_version_writes_a_pin_mecha10_setup_can_read() {
241        let temp = TempDir::new().unwrap();
242        let service = ProjectTemplateService::new();
243
244        service.create_python_version(temp.path()).await.unwrap();
245
246        let content = tokio::fs::read_to_string(temp.path().join(".python-version"))
247            .await
248            .unwrap();
249        let mut parts = content.trim().split('.');
250        assert!(parts.next().unwrap().parse::<u32>().is_ok());
251        assert!(parts.next().unwrap().parse::<u32>().is_ok());
252        assert!(parts.next().is_none());
253    }
254
255    #[tokio::test]
256    async fn create_cargo_toml_includes_project_name() {
257        let temp = TempDir::new().unwrap();
258        let service = ProjectTemplateService::new();
259
260        service.create_cargo_toml(temp.path(), "my-robot").await.unwrap();
261
262        let content = tokio::fs::read_to_string(temp.path().join("Cargo.toml")).await.unwrap();
263        assert!(content.contains("my-robot"));
264    }
265
266    #[tokio::test]
267    async fn create_main_rs_writes_file() {
268        let temp = TempDir::new().unwrap();
269        let service = ProjectTemplateService::new();
270
271        service.create_main_rs(temp.path(), "my-robot").await.unwrap();
272
273        assert!(temp.path().join("src/main.rs").exists());
274    }
275
276    #[tokio::test]
277    async fn create_rl_training_bundle_requires_requirements_txt_created_first() {
278        let temp = TempDir::new().unwrap();
279        let service = ProjectTemplateService::new();
280
281        service.create_rl_training_bundle(temp.path()).await.unwrap();
282
283        assert!(temp.path().join("configs/rl/jobs/train.yml").exists());
284        assert!(temp.path().join("requirements.txt").exists());
285        assert!(!temp.path().join("kestra").exists());
286    }
287}