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
23// Embedded configuration templates
24//
25// These two are the last-resort placeholder catalog written by `create_simulation_model_json`/
26// `create_simulation_environment_json` (see `mecha10_cli::handlers::init::
27// ensure_simulation_catalog_fallback`) when the downloaded templates archive didn't populate the
28// real bundled simulation asset catalog. Distinct from the compile-time embedded-fallback *tier*
29// removed in LAB-2034 (`create_simulation_configs`/`create_simulation_assets`/
30// `create_behavior_templates`/`create_node_configs`, formerly below) - those existed to replace
31// the entire downloaded-archive template-resolution path, not just backstop a missing catalog
32// within it.
33const MODEL_JSON_TEMPLATE: &str = include_str!("../../templates/config/model.json.template");
34const ENVIRONMENT_JSON_TEMPLATE: &str = include_str!("../../templates/config/environment.json.template");
35
36/// Service for generating project template files
37///
38/// # Examples
39///
40/// ```rust,ignore
41/// use mecha10_cli::services::ProjectTemplateService;
42///
43/// let service = ProjectTemplateService::new();
44///
45/// // Generate all project files
46/// service.create_readme(&project_path, "my-robot").await?;
47/// service.create_cargo_toml(&project_path, "my-robot").await?;
48/// ```
49pub struct ProjectTemplateService {
50    rust: RustTemplates,
51    infra: InfraTemplates,
52    meta: MetaTemplates,
53    rl_bundle: RlBundleTemplates,
54}
55
56impl ProjectTemplateService {
57    /// Create a new ProjectTemplateService
58    pub fn new() -> Self {
59        Self {
60            rust: RustTemplates::new(),
61            infra: InfraTemplates::new(),
62            meta: MetaTemplates::new(),
63            rl_bundle: RlBundleTemplates::new(),
64        }
65    }
66
67    /// Create README.md for the project
68    pub async fn create_readme(&self, path: &Path, project_name: &str) -> Result<()> {
69        self.meta.create_readme(path, project_name).await
70    }
71
72    /// Create .gitignore for the project
73    pub async fn create_gitignore(&self, path: &Path) -> Result<()> {
74        self.meta.create_gitignore(path).await
75    }
76
77    /// Create Cargo.toml for the project
78    ///
79    /// # Arguments
80    ///
81    /// * `path` - Project root path
82    /// * `project_name` - Name of the project
83    pub async fn create_cargo_toml(&self, path: &Path, project_name: &str) -> Result<()> {
84        self.rust.create_cargo_toml(path, project_name).await
85    }
86
87    /// Create src/main.rs for the project
88    pub async fn create_main_rs(&self, path: &Path, project_name: &str) -> Result<()> {
89        self.rust.create_main_rs(path, project_name).await
90    }
91
92    /// Create build.rs for the project
93    ///
94    /// This build script generates:
95    /// 1. node_registry.rs - Node dispatcher based on mecha10.json
96    pub async fn create_build_rs(&self, path: &Path) -> Result<()> {
97        self.rust.create_build_rs(path).await
98    }
99
100    /// Create .env.example for the project
101    ///
102    /// # Arguments
103    ///
104    /// * `path` - Project root path
105    /// * `project_name` - Name of the project
106    pub async fn create_env_example(&self, path: &Path, project_name: &str) -> Result<()> {
107        self.infra.create_env_example(path, project_name).await
108    }
109
110    /// Create rustfmt.toml for the project
111    pub async fn create_rustfmt_toml(&self, path: &Path) -> Result<()> {
112        self.rust.create_rustfmt_toml(path).await
113    }
114
115    /// Create docker-compose.yml for the project
116    ///
117    /// Generates a Docker Compose file with Redis and optional PostgreSQL services
118    /// for local development and deployment.
119    ///
120    /// # Arguments
121    ///
122    /// * `path` - Project root path
123    /// * `project_name` - Name of the project (used for container naming)
124    pub async fn create_docker_compose(&self, path: &Path, project_name: &str) -> Result<()> {
125        self.infra.create_docker_compose(path, project_name).await
126    }
127
128    /// Create remote node Docker files
129    ///
130    /// Creates docker-compose.remote.yml and Dockerfile.remote for running
131    /// AI/ML nodes in containers. These are used when nodes are configured
132    /// in targets.remote in mecha10.json.
133    ///
134    /// # Arguments
135    ///
136    /// * `path` - Project root path
137    pub async fn create_remote_docker_files(&self, path: &Path) -> Result<()> {
138        self.infra.create_remote_docker_files(path).await
139    }
140
141    /// Create package.json for npm dependencies
142    ///
143    /// Includes @mecha10/simulation-models and @mecha10/simulation-environments dependencies,
144    /// resolved via node_modules/@mecha10/* (requires npm install).
145    ///
146    /// # Arguments
147    ///
148    /// * `path` - Project root path
149    /// * `project_name` - Name of the project
150    pub async fn create_package_json(&self, path: &Path, project_name: &str) -> Result<()> {
151        self.meta.create_package_json(path, project_name).await
152    }
153
154    /// Create requirements.txt for Python dependencies
155    ///
156    /// Includes Python packages needed for AI nodes (onnx, onnxruntime).
157    /// Install with: `mecha10 setup` or `pip install -r requirements.txt`
158    ///
159    /// # Arguments
160    ///
161    /// * `path` - Project root path
162    pub async fn create_requirements_txt(&self, path: &Path) -> Result<()> {
163        self.meta.create_requirements_txt(path).await
164    }
165
166    /// Create the standard RL training bundle (LAB-1743, Kestra flows added in LAB-1765) for a
167    /// `rover` template project
168    ///
169    /// Bundles the Phase 2 `rover` env / `ppo` algorithm (`packages/rl-trainer`,
170    /// `packages/rl-task-runner`, registered via the `mecha10_rl.envs`/`mecha10_rl.algorithms`
171    /// entry-points registry, LAB-1732/1733) into three pieces:
172    ///
173    /// - `configs/rl/local.yml` - the default sentinel-override config for the combination
174    ///   (LAB-1734's `rl_task_runner.local_config` pattern).
175    /// - `requirements.txt` - appended with the two packages' dependencies, since both already
176    ///   register their entry points in their own `pyproject.toml` (nothing else to declare).
177    /// - `kestra/README.md` and `kestra/flows/ml-pipeline/*.yml` - the real `train`/`tune`/
178    ///   `tune-then-train`/`train-from-tune`/`resume-run` Kestra flows (LAB-1745), embedded and
179    ///   bundled by LAB-1765 so new projects get working flows out of the box.
180    ///
181    /// Callers should invoke `create_requirements_txt` first — this appends to that file rather
182    /// than creating it.
183    ///
184    /// # Arguments
185    ///
186    /// * `path` - Project root path
187    pub async fn create_rl_training_bundle(&self, path: &Path) -> Result<()> {
188        self.rl_bundle.create_rl_config(path).await?;
189        self.rl_bundle.append_rl_requirements(path).await?;
190        self.rl_bundle.create_kestra_flows(path).await?;
191        self.rl_bundle.create_rl_jobs(path).await?;
192        Ok(())
193    }
194
195    /// Create simulation/models/model.json
196    ///
197    /// Generates the rover robot physical model configuration
198    /// from embedded template.
199    ///
200    /// # Arguments
201    ///
202    /// * `path` - Project root directory
203    pub async fn create_simulation_model_json(&self, path: &Path) -> Result<()> {
204        let model_dest = path.join(paths::project::model_config("rover"));
205
206        // Ensure destination directory exists
207        if let Some(parent) = model_dest.parent() {
208            tokio::fs::create_dir_all(parent).await?;
209        }
210
211        // Write embedded model.json template
212        tokio::fs::write(&model_dest, MODEL_JSON_TEMPLATE)
213            .await
214            .context("Failed to write model.json")?;
215
216        Ok(())
217    }
218
219    /// Create simulation/environments/basic_arena/environment.json
220    ///
221    /// Generates the basic arena environment configuration
222    /// from embedded template.
223    ///
224    /// # Arguments
225    ///
226    /// * `path` - Project root directory
227    pub async fn create_simulation_environment_json(&self, path: &Path) -> Result<()> {
228        let env_dest = path.join(paths::project::environment_config("basic_arena"));
229
230        // Ensure destination directory exists
231        if let Some(parent) = env_dest.parent() {
232            tokio::fs::create_dir_all(parent).await?;
233        }
234
235        // Write embedded environment.json template
236        tokio::fs::write(&env_dest, ENVIRONMENT_JSON_TEMPLATE)
237            .await
238            .context("Failed to write environment.json")?;
239
240        Ok(())
241    }
242}
243
244impl Default for ProjectTemplateService {
245    fn default() -> Self {
246        Self::new()
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use tempfile::TempDir;
254
255    #[tokio::test]
256    async fn create_readme_replaces_project_name_placeholder() {
257        let temp = TempDir::new().unwrap();
258        let service = ProjectTemplateService::new();
259
260        service.create_readme(temp.path(), "my-robot").await.unwrap();
261
262        let content = tokio::fs::read_to_string(temp.path().join("README.md")).await.unwrap();
263        assert!(content.contains("my-robot"));
264        assert!(!content.contains("{{project_name}}"));
265    }
266
267    #[tokio::test]
268    async fn create_gitignore_writes_file() {
269        let temp = TempDir::new().unwrap();
270        let service = ProjectTemplateService::new();
271
272        service.create_gitignore(temp.path()).await.unwrap();
273
274        assert!(temp.path().join(".gitignore").exists());
275    }
276
277    #[tokio::test]
278    async fn create_cargo_toml_includes_project_name() {
279        let temp = TempDir::new().unwrap();
280        let service = ProjectTemplateService::new();
281
282        service.create_cargo_toml(temp.path(), "my-robot").await.unwrap();
283
284        let content = tokio::fs::read_to_string(temp.path().join("Cargo.toml")).await.unwrap();
285        assert!(content.contains("my-robot"));
286    }
287
288    #[tokio::test]
289    async fn create_main_rs_writes_file() {
290        let temp = TempDir::new().unwrap();
291        let service = ProjectTemplateService::new();
292
293        service.create_main_rs(temp.path(), "my-robot").await.unwrap();
294
295        assert!(temp.path().join("src/main.rs").exists());
296    }
297
298    #[tokio::test]
299    async fn create_simulation_model_json_writes_expected_path() {
300        let temp = TempDir::new().unwrap();
301        let service = ProjectTemplateService::new();
302
303        service.create_simulation_model_json(temp.path()).await.unwrap();
304
305        let dest = temp.path().join(paths::project::model_config("rover"));
306        assert!(dest.exists());
307        let content = tokio::fs::read_to_string(&dest).await.unwrap();
308        assert_eq!(content, MODEL_JSON_TEMPLATE);
309    }
310
311    #[tokio::test]
312    async fn create_simulation_environment_json_writes_expected_path() {
313        let temp = TempDir::new().unwrap();
314        let service = ProjectTemplateService::new();
315
316        service.create_simulation_environment_json(temp.path()).await.unwrap();
317
318        let dest = temp.path().join(paths::project::environment_config("basic_arena"));
319        assert!(dest.exists());
320        let content = tokio::fs::read_to_string(&dest).await.unwrap();
321        assert_eq!(content, ENVIRONMENT_JSON_TEMPLATE);
322    }
323
324    #[tokio::test]
325    async fn create_simulation_model_json_creates_missing_parent_dirs() {
326        // Regression guard: destination is nested (simulation/models/), so the
327        // service must create parent directories itself rather than assuming
328        // they already exist.
329        let temp = TempDir::new().unwrap();
330        let service = ProjectTemplateService::new();
331
332        assert!(!temp.path().join("simulation").exists());
333
334        service.create_simulation_model_json(temp.path()).await.unwrap();
335
336        assert!(temp.path().join("simulation/models").is_dir());
337    }
338
339    #[tokio::test]
340    async fn create_rl_training_bundle_requires_requirements_txt_created_first() {
341        // create_rl_training_bundle appends to requirements.txt; per its own
342        // doc comment, callers must create_requirements_txt first. Verify the
343        // bundle still produces its own files even when that hasn't happened,
344        // since append-to-missing-file is handled internally by RlBundleTemplates.
345        let temp = TempDir::new().unwrap();
346        let service = ProjectTemplateService::new();
347
348        service.create_rl_training_bundle(temp.path()).await.unwrap();
349
350        assert!(temp.path().join("configs/rl/local.yml").exists());
351        assert!(temp.path().join("requirements.txt").exists());
352        assert!(temp.path().join("kestra/README.md").exists());
353    }
354}