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}