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}