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::paths;
19use crate::templates::{InfraTemplates, MetaTemplates, RlBundleTemplates, RustTemplates};
20use anyhow::{Context, Result};
21use std::path::Path;
22
23const MODEL_JSON_TEMPLATE: &str = include_str!("../../templates/config/model.json.template");
24const ENVIRONMENT_JSON_TEMPLATE: &str = include_str!("../../templates/config/environment.json.template");
25const CAT_ENVIRONMENT_JSON_TEMPLATE: &str = include_str!("../../templates/config/cat_environment.json.template");
26
27/// Generates a `pub async fn $name(&self, path: &Path[, project_name: &str]) -> Result<()>`
28/// wrapper that forwards straight to `self.$field.$name(..)`.
29///
30/// `ProjectTemplateService` is a thin facade over the `rust`/`infra`/`meta` template modules -
31/// every one of these wrappers has an identical `self.<field>.<method>(<args>).await` body, which
32/// `cargo-dupes` (correctly) flagged as an exact-duplicate cluster (LAB-2467): the bodies really
33/// are byte-for-byte the same shape, differing only in which field/method they name. A macro
34/// collapses that to a single written definition instead of one copy per forwarded method.
35macro_rules! delegate_create {
36    ($(#[$meta:meta])* $name:ident($field:ident)) => {
37        $(#[$meta])*
38        pub async fn $name(&self, path: &Path) -> Result<()> {
39            self.$field.$name(path).await
40        }
41    };
42    ($(#[$meta:meta])* $name:ident($field:ident, project_name)) => {
43        $(#[$meta])*
44        pub async fn $name(&self, path: &Path, project_name: &str) -> Result<()> {
45            self.$field.$name(path, project_name).await
46        }
47    };
48}
49
50/// Generates a `pub async fn $name(&self, path: &Path) -> Result<()>` that joins `path` with
51/// `$rel_path`, then writes `$template` there via [`ProjectTemplateService::write_embedded_config`]
52/// (LAB-2467: `create_simulation_model_json`/`create_simulation_environment_json` were previously
53/// two hand-written copies of this join-then-write shape).
54macro_rules! create_config_file {
55    ($(#[$meta:meta])* $name:ident($rel_path:expr, $template:expr, $err_context:literal)) => {
56        $(#[$meta])*
57        pub async fn $name(&self, path: &Path) -> Result<()> {
58            let dest = path.join($rel_path);
59            Self::write_embedded_config(&dest, $template, $err_context).await
60        }
61    };
62}
63
64/// Service for generating project template files
65///
66/// # Examples
67///
68/// ```rust,ignore
69/// use mecha10_cli::services::ProjectTemplateService;
70///
71/// let service = ProjectTemplateService::new();
72///
73/// // Generate all project files
74/// service.create_readme(&project_path, "my-robot").await?;
75/// service.create_cargo_toml(&project_path, "my-robot").await?;
76/// ```
77pub struct ProjectTemplateService {
78    rust: RustTemplates,
79    infra: InfraTemplates,
80    meta: MetaTemplates,
81    rl_bundle: RlBundleTemplates,
82}
83
84impl ProjectTemplateService {
85    /// Create a new ProjectTemplateService
86    pub fn new() -> Self {
87        Self {
88            rust: RustTemplates::new(),
89            infra: InfraTemplates::new(),
90            meta: MetaTemplates::new(),
91            rl_bundle: RlBundleTemplates::new(),
92        }
93    }
94
95    delegate_create!(
96        /// Create README.md for the project
97        create_readme(meta, project_name)
98    );
99
100    delegate_create!(
101        /// Create .gitignore for the project
102        create_gitignore(meta)
103    );
104
105    delegate_create!(
106        /// Create Cargo.toml for the project
107        ///
108        /// # Arguments
109        ///
110        /// * `path` - Project root path
111        /// * `project_name` - Name of the project
112        create_cargo_toml(rust, project_name)
113    );
114
115    delegate_create!(
116        /// Create src/main.rs for the project
117        create_main_rs(rust, project_name)
118    );
119
120    delegate_create!(
121        /// Create build.rs for the project
122        ///
123        /// This build script generates:
124        /// 1. node_registry.rs - Node dispatcher based on mecha10.json
125        create_build_rs(rust)
126    );
127
128    delegate_create!(
129        /// Create .env.example for the project
130        ///
131        /// # Arguments
132        ///
133        /// * `path` - Project root path
134        /// * `project_name` - Name of the project
135        create_env_example(infra, project_name)
136    );
137
138    delegate_create!(
139        /// Create rustfmt.toml for the project
140        create_rustfmt_toml(rust)
141    );
142
143    delegate_create!(
144        /// Create docker-compose.yml for the project
145        ///
146        /// Generates a Docker Compose file with Redis and optional PostgreSQL services
147        /// for local development and deployment.
148        ///
149        /// # Arguments
150        ///
151        /// * `path` - Project root path
152        /// * `project_name` - Name of the project (used for container naming)
153        create_docker_compose(infra, project_name)
154    );
155
156    delegate_create!(
157        /// Create remote node Docker files
158        ///
159        /// Creates docker-compose.remote.yml and Dockerfile.remote for running
160        /// AI/ML nodes in containers. These are used when nodes are configured
161        /// in targets.remote in mecha10.json.
162        ///
163        /// # Arguments
164        ///
165        /// * `path` - Project root path
166        create_remote_docker_files(infra)
167    );
168
169    delegate_create!(
170        /// Create rl target Docker files
171        ///
172        /// Creates docker-compose.rl.yml, Dockerfile.rl, and entrypoint-rl.sh for running a
173        /// trained RL policy in production. Modeled on `create_remote_docker_files`, but
174        /// deliberately excludes the simulator - see that method's doc comment.
175        ///
176        /// # Arguments
177        ///
178        /// * `path` - Project root path
179        create_rl_docker_files(infra)
180    );
181
182    delegate_create!(
183        /// Create package.json for npm dependencies
184        ///
185        /// Includes @mecha10/simulation-models and @mecha10/simulation-environments dependencies,
186        /// resolved via node_modules/@mecha10/* (requires npm install).
187        ///
188        /// # Arguments
189        ///
190        /// * `path` - Project root path
191        /// * `project_name` - Name of the project
192        create_package_json(meta, project_name)
193    );
194
195    delegate_create!(
196        /// Create requirements.txt for Python dependencies
197        ///
198        /// Includes Python packages needed for AI nodes (onnx, onnxruntime).
199        /// Install with: `mecha10 setup` or `pip install -r requirements.txt`
200        ///
201        /// # Arguments
202        ///
203        /// * `path` - Project root path
204        create_requirements_txt(meta)
205    );
206
207    delegate_create!(
208        /// Create `.python-version`, pinning the exact Python version `mecha10 setup`
209        /// should target for the RL/simulation venv.
210        ///
211        /// # Arguments
212        ///
213        /// * `path` - Project root path
214        create_python_version(meta)
215    );
216
217    /// Create the standard RL training bundle (LAB-1743) for a `rover` template project
218    ///
219    /// Bundles the Phase 2 `rover` env / `ppo` algorithm (`packages/rl-trainer`,
220    /// `packages/rl-task-runner`, registered via the `mecha10_rl.envs`/`mecha10_rl.algorithms`
221    /// entry-points registry, LAB-1732/1733) into two pieces:
222    ///
223    /// - `configs/rl/jobs/` - the standard job catalog; each job file is a complete training
224    ///   config (job identity plus every `PPOConfig` hyperparameter, LAB-2970) - there's no more
225    ///   separate `configs/rl/local.yml` sentinel-override config to layer onto.
226    /// - `requirements.txt` - appended with the two packages' dependencies, since both already
227    ///   register their entry points in their own `pyproject.toml` (nothing else to declare).
228    ///
229    /// A project no longer gets its own `kestra/` directory (LAB-2428) - a robot developer
230    /// shouldn't need to know Kestra is involved; the canonical `train`/`tune`/`tune-then-train`/
231    /// `train-from-tune`/`resume-run` flows live solely in `mecha10-rl`, deployed via its own CI.
232    ///
233    /// Callers should invoke `create_requirements_txt` first — this appends to that file rather
234    /// than creating it.
235    ///
236    /// # Arguments
237    ///
238    /// * `path` - Project root path
239    pub async fn create_rl_training_bundle(&self, path: &Path) -> Result<()> {
240        self.rl_bundle.append_rl_requirements(path).await?;
241        self.rl_bundle.create_rl_jobs(path).await?;
242        Ok(())
243    }
244
245    create_config_file!(
246        /// Create configs/simulation/models/model.json
247        ///
248        /// Generates the rover robot physical model configuration
249        /// from embedded template.
250        ///
251        /// # Arguments
252        ///
253        /// * `path` - Project root directory
254        create_simulation_model_json(
255            paths::project::model_config("rover"),
256            MODEL_JSON_TEMPLATE,
257            "Failed to write model.json"
258        )
259    );
260
261    create_config_file!(
262        /// Create configs/simulation/environments/basic_arena/environment.json
263        ///
264        /// Generates the basic arena environment configuration
265        /// from embedded template.
266        ///
267        /// # Arguments
268        ///
269        /// * `path` - Project root directory
270        create_simulation_environment_json(
271            paths::project::environment_config("basic_arena"),
272            ENVIRONMENT_JSON_TEMPLATE,
273            "Failed to write environment.json"
274        )
275    );
276
277    create_config_file!(
278        /// Create configs/simulation/environments/cat_environment/environment.json
279        ///
280        /// `mecha10.json`'s default `simulation.environment` (see `mecha10.json.template`) - a
281        /// generic `basic_arena` plus two cat images, so `mecha10 dev`'s simulator preview has
282        /// something to detect out of the box. RL training (`configs/rl/jobs/train.yml`) still
283        /// targets plain `basic_arena` - the two environments are scaffolded independently.
284        ///
285        /// # Arguments
286        ///
287        /// * `path` - Project root directory
288        create_simulation_cat_environment_json(
289            paths::project::environment_config("cat_environment"),
290            CAT_ENVIRONMENT_JSON_TEMPLATE,
291            "Failed to write cat_environment environment.json"
292        )
293    );
294
295    /// Write an embedded config template to `dest`, creating any missing parent
296    /// directories first.
297    ///
298    /// Shared by `create_simulation_model_json`/`create_simulation_environment_json`,
299    /// which were previously two copies of this exact create-parent-dir-then-write
300    /// sequence (LAB-2467).
301    ///
302    /// # Arguments
303    ///
304    /// * `dest` - Full destination file path
305    /// * `template` - Embedded template content to write verbatim
306    /// * `error_context` - Static message attached if the write fails
307    async fn write_embedded_config(dest: &Path, template: &str, error_context: &'static str) -> Result<()> {
308        if let Some(parent) = dest.parent() {
309            tokio::fs::create_dir_all(parent).await?;
310        }
311
312        tokio::fs::write(dest, template).await.context(error_context)?;
313
314        Ok(())
315    }
316}
317
318impl Default for ProjectTemplateService {
319    fn default() -> Self {
320        Self::new()
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use tempfile::TempDir;
328
329    #[tokio::test]
330    async fn create_readme_replaces_project_name_placeholder() {
331        let temp = TempDir::new().unwrap();
332        let service = ProjectTemplateService::new();
333
334        service.create_readme(temp.path(), "my-robot").await.unwrap();
335
336        let content = tokio::fs::read_to_string(temp.path().join("README.md")).await.unwrap();
337        assert!(content.contains("my-robot"));
338        assert!(!content.contains("{{project_name}}"));
339    }
340
341    #[tokio::test]
342    async fn create_gitignore_writes_file() {
343        let temp = TempDir::new().unwrap();
344        let service = ProjectTemplateService::new();
345
346        service.create_gitignore(temp.path()).await.unwrap();
347
348        assert!(temp.path().join(".gitignore").exists());
349    }
350
351    #[tokio::test]
352    async fn create_python_version_writes_a_pin_mecha10_setup_can_read() {
353        let temp = TempDir::new().unwrap();
354        let service = ProjectTemplateService::new();
355
356        service.create_python_version(temp.path()).await.unwrap();
357
358        let content = tokio::fs::read_to_string(temp.path().join(".python-version"))
359            .await
360            .unwrap();
361        let mut parts = content.trim().split('.');
362        assert!(parts.next().unwrap().parse::<u32>().is_ok());
363        assert!(parts.next().unwrap().parse::<u32>().is_ok());
364        assert!(parts.next().is_none());
365    }
366
367    #[tokio::test]
368    async fn create_cargo_toml_includes_project_name() {
369        let temp = TempDir::new().unwrap();
370        let service = ProjectTemplateService::new();
371
372        service.create_cargo_toml(temp.path(), "my-robot").await.unwrap();
373
374        let content = tokio::fs::read_to_string(temp.path().join("Cargo.toml")).await.unwrap();
375        assert!(content.contains("my-robot"));
376    }
377
378    #[tokio::test]
379    async fn create_main_rs_writes_file() {
380        let temp = TempDir::new().unwrap();
381        let service = ProjectTemplateService::new();
382
383        service.create_main_rs(temp.path(), "my-robot").await.unwrap();
384
385        assert!(temp.path().join("src/main.rs").exists());
386    }
387
388    #[tokio::test]
389    async fn create_simulation_model_json_writes_expected_path() {
390        let temp = TempDir::new().unwrap();
391        let service = ProjectTemplateService::new();
392
393        service.create_simulation_model_json(temp.path()).await.unwrap();
394
395        let dest = temp.path().join(paths::project::model_config("rover"));
396        assert!(dest.exists());
397        let content = tokio::fs::read_to_string(&dest).await.unwrap();
398        assert_eq!(content, MODEL_JSON_TEMPLATE);
399    }
400
401    #[tokio::test]
402    async fn create_simulation_environment_json_writes_expected_path() {
403        let temp = TempDir::new().unwrap();
404        let service = ProjectTemplateService::new();
405
406        service.create_simulation_environment_json(temp.path()).await.unwrap();
407
408        let dest = temp.path().join(paths::project::environment_config("basic_arena"));
409        assert!(dest.exists());
410        let content = tokio::fs::read_to_string(&dest).await.unwrap();
411        assert_eq!(content, ENVIRONMENT_JSON_TEMPLATE);
412    }
413
414    #[tokio::test]
415    async fn create_simulation_model_json_creates_missing_parent_dirs() {
416        let temp = TempDir::new().unwrap();
417        let service = ProjectTemplateService::new();
418
419        assert!(!temp.path().join("configs/simulation").exists());
420
421        service.create_simulation_model_json(temp.path()).await.unwrap();
422
423        assert!(temp.path().join("configs/simulation/models").is_dir());
424    }
425
426    #[tokio::test]
427    async fn create_rl_training_bundle_requires_requirements_txt_created_first() {
428        let temp = TempDir::new().unwrap();
429        let service = ProjectTemplateService::new();
430
431        service.create_rl_training_bundle(temp.path()).await.unwrap();
432
433        assert!(temp.path().join("configs/rl/jobs/train.yml").exists());
434        assert!(temp.path().join("requirements.txt").exists());
435        assert!(!temp.path().join("kestra").exists());
436    }
437}