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}