Skip to main content

mecha10_templates/services/
project_template_service.rs

1//! Project template service for generating project files
2//!
3//! Coordinates template generation across the Rust/Infra/Meta template modules.
4
5use crate::templates::{DatasetBundleTemplates, InfraTemplates, MetaTemplates, RlBundleTemplates, RustTemplates};
6use anyhow::Result;
7use std::path::Path;
8
9macro_rules! delegate_create {
10    ($(#[$meta:meta])* $name:ident($field:ident)) => {
11        $(#[$meta])*
12        pub async fn $name(&self, path: &Path) -> Result<()> {
13            self.$field.$name(path).await
14        }
15    };
16    ($(#[$meta:meta])* $name:ident($field:ident, project_name)) => {
17        $(#[$meta])*
18        pub async fn $name(&self, path: &Path, project_name: &str) -> Result<()> {
19            self.$field.$name(path, project_name).await
20        }
21    };
22    ($(#[$meta:meta])* $name:ident($field:ident, assets_root)) => {
23        $(#[$meta])*
24        pub async fn $name(&self, path: &Path, assets_root: &Path) -> Result<()> {
25            self.$field.$name(path, assets_root).await
26        }
27    };
28    ($(#[$meta:meta])* $name:ident($field:ident, project_name, assets_root)) => {
29        $(#[$meta])*
30        pub async fn $name(&self, path: &Path, project_name: &str, assets_root: &Path) -> Result<()> {
31            self.$field.$name(path, project_name, assets_root).await
32        }
33    };
34}
35
36pub struct ProjectTemplateService {
37    rust: RustTemplates,
38    infra: InfraTemplates,
39    meta: MetaTemplates,
40    rl_bundle: RlBundleTemplates,
41    dataset_bundle: DatasetBundleTemplates,
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            dataset_bundle: DatasetBundleTemplates::new(),
53        }
54    }
55
56    delegate_create!(
57        /// Create README.md for the project, reading the template from the freshly-fetched
58        /// `mecha10-assets` extraction at `assets_root`.
59        create_readme(meta, project_name, assets_root)
60    );
61
62    delegate_create!(
63        /// Create .gitignore for the project, reading the template from the freshly-fetched
64        /// `mecha10-assets` extraction at `assets_root`.
65        create_gitignore(meta, assets_root)
66    );
67
68    delegate_create!(
69        /// Create Cargo.toml for the project, reading the template from the freshly-fetched
70        /// `mecha10-assets` extraction at `assets_root`.
71        create_cargo_toml(rust, project_name, assets_root)
72    );
73
74    delegate_create!(
75        /// Create src/main.rs for the project, reading the template from the freshly-fetched
76        /// `mecha10-assets` extraction at `assets_root`.
77        create_main_rs(rust, project_name, assets_root)
78    );
79
80    delegate_create!(
81        /// Create build.rs for the project, reading the template from the freshly-fetched
82        /// `mecha10-assets` extraction at `assets_root`.
83        ///
84        /// Generates node_registry.rs, the node dispatcher based on mecha10.json.
85        create_build_rs(rust, assets_root)
86    );
87
88    delegate_create!(
89        /// Create .env.example for the project, reading the template from the freshly-fetched
90        /// `mecha10-assets` extraction at `assets_root`.
91        create_env_example(infra, project_name, assets_root)
92    );
93
94    /// Create rustfmt.toml for the project, reading the template from the freshly-fetched
95    /// `mecha10-assets` extraction at `assets_root`.
96    pub async fn create_rustfmt_toml(&self, path: &Path, assets_root: &Path) -> Result<()> {
97        self.rust.create_rustfmt_toml(path, assets_root).await
98    }
99
100    delegate_create!(
101        /// Create docker-compose.yml for the project with Redis and optional PostgreSQL
102        /// services, reading the template from the freshly-fetched `mecha10-assets` extraction
103        /// at `assets_root`.
104        create_docker_compose(infra, project_name, assets_root)
105    );
106
107    delegate_create!(
108        /// Create docker-compose.remote.yml and Dockerfile.remote for AI/ML nodes configured
109        /// in targets.remote in mecha10.json, reading the templates from the freshly-fetched
110        /// `mecha10-assets` extraction at `assets_root`.
111        create_remote_docker_files(infra, assets_root)
112    );
113
114    delegate_create!(
115        /// Create docker-compose.rl.yml, Dockerfile.rl, and entrypoint-rl.sh for running a
116        /// trained RL policy in production, reading the templates from the freshly-fetched
117        /// `mecha10-assets` extraction at `assets_root`.
118        create_rl_docker_files(infra, assets_root)
119    );
120
121    delegate_create!(
122        /// Create package.json for npm dependencies (requires npm install), reading the
123        /// template from the freshly-fetched `mecha10-assets` extraction at `assets_root`.
124        create_package_json(meta, project_name, assets_root)
125    );
126
127    delegate_create!(
128        /// Create requirements.txt for Python dependencies needed for AI nodes, reading the
129        /// template from the freshly-fetched `mecha10-assets` extraction at `assets_root`.
130        create_requirements_txt(meta, assets_root)
131    );
132
133    delegate_create!(
134        /// Create `.python-version`, pinning the Python version `mecha10 setup` should target,
135        /// reading the template from the freshly-fetched `mecha10-assets` extraction at
136        /// `assets_root`.
137        create_python_version(meta, assets_root)
138    );
139
140    /// Create the standard RL training bundle for a `rover` template project, reading its job
141    /// templates and Python requirements from the freshly-fetched `mecha10-assets` extraction at
142    /// `assets_root`.
143    ///
144    /// Callers should invoke `create_requirements_txt` first — this appends to that file rather
145    pub async fn create_rl_training_bundle(&self, path: &Path, assets_root: &Path) -> Result<()> {
146        self.rl_bundle.append_rl_requirements(path, assets_root).await?;
147        self.rl_bundle.create_rl_jobs(path, assets_root).await?;
148        Ok(())
149    }
150
151    /// Create the starter L6 dataset-build job (`configs/datasets/jobs/build-dataset.yml`),
152    /// reading it from the freshly-fetched `mecha10-assets` extraction at `assets_root`. Builds
153    /// a new dataset version from a project's recorded episodes (stage 7's aggregation/
154    /// norm-stats/LeRobot-metadata pipeline) - separate from [`Self::create_rl_training_bundle`]
155    /// since it isn't a `mecha10 train` job (see `DatasetBundleTemplates`' doc comment).
156    ///
157    /// Returns whether the job was actually written (`false` if `mecha10-assets` hasn't
158    /// published the template yet) - see `DatasetBundleTemplates::create_dataset_jobs`.
159    pub async fn create_dataset_build_job(&self, path: &Path, assets_root: &Path) -> Result<bool> {
160        self.dataset_bundle.create_dataset_jobs(path, assets_root).await
161    }
162}
163
164impl Default for ProjectTemplateService {
165    fn default() -> Self {
166        Self::new()
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use tempfile::TempDir;
174
175    /// Stage a fake extracted `mecha10-assets` root containing a single `project/<name>`
176    /// template file, mirroring the real `mecha10-assets` layout.
177    fn stage_assets_root_with(name: &str, content: &str) -> TempDir {
178        let assets_root = TempDir::new().unwrap();
179        let project_dir = assets_root.path().join("project");
180        std::fs::create_dir_all(&project_dir).unwrap();
181        std::fs::write(project_dir.join(name), content).unwrap();
182        assets_root
183    }
184
185    #[tokio::test]
186    async fn create_readme_replaces_project_name_placeholder() {
187        let temp = TempDir::new().unwrap();
188        let assets_root = stage_assets_root_with("README.md.template", "# {{project_name}}\n");
189        let service = ProjectTemplateService::new();
190
191        service
192            .create_readme(temp.path(), "my-robot", assets_root.path())
193            .await
194            .unwrap();
195
196        let content = tokio::fs::read_to_string(temp.path().join("README.md")).await.unwrap();
197        assert!(content.contains("my-robot"));
198        assert!(!content.contains("{{project_name}}"));
199    }
200
201    #[tokio::test]
202    async fn create_gitignore_writes_file() {
203        let temp = TempDir::new().unwrap();
204        let assets_root = stage_assets_root_with(".gitignore.template", "target/\n");
205        let service = ProjectTemplateService::new();
206
207        service.create_gitignore(temp.path(), assets_root.path()).await.unwrap();
208
209        assert!(temp.path().join(".gitignore").exists());
210    }
211
212    #[tokio::test]
213    async fn create_python_version_writes_a_pin_mecha10_setup_can_read() {
214        let temp = TempDir::new().unwrap();
215        let assets_root = stage_assets_root_with(".python-version.template", "3.11\n");
216        let service = ProjectTemplateService::new();
217
218        service
219            .create_python_version(temp.path(), assets_root.path())
220            .await
221            .unwrap();
222
223        let content = tokio::fs::read_to_string(temp.path().join(".python-version"))
224            .await
225            .unwrap();
226        let mut parts = content.trim().split('.');
227        assert!(parts.next().unwrap().parse::<u32>().is_ok());
228        assert!(parts.next().unwrap().parse::<u32>().is_ok());
229        assert!(parts.next().is_none());
230    }
231
232    #[tokio::test]
233    async fn create_cargo_toml_includes_project_name() {
234        let temp = TempDir::new().unwrap();
235        let assets_root =
236            stage_assets_root_with("Cargo.toml.prod.template", "[package]\nname = \"{{project_name}}\"\n");
237        let service = ProjectTemplateService::new();
238
239        service
240            .create_cargo_toml(temp.path(), "my-robot", assets_root.path())
241            .await
242            .unwrap();
243
244        let content = tokio::fs::read_to_string(temp.path().join("Cargo.toml")).await.unwrap();
245        assert!(content.contains("my-robot"));
246    }
247
248    #[tokio::test]
249    async fn create_main_rs_writes_file() {
250        let temp = TempDir::new().unwrap();
251        let assets_root = stage_assets_root_with("main.rs.template", "fn main() {}\n");
252        let service = ProjectTemplateService::new();
253
254        service
255            .create_main_rs(temp.path(), "my-robot", assets_root.path())
256            .await
257            .unwrap();
258
259        assert!(temp.path().join("src/main.rs").exists());
260    }
261
262    #[tokio::test]
263    async fn create_rl_training_bundle_requires_requirements_txt_created_first() {
264        let temp = TempDir::new().unwrap();
265        let assets_root = TempDir::new().unwrap();
266        let jobs_dir = assets_root.path().join("rl/jobs");
267        std::fs::create_dir_all(&jobs_dir).unwrap();
268        std::fs::write(jobs_dir.join("train.yml.template"), "flow_type: train\n").unwrap();
269        std::fs::write(jobs_dir.join("resume-train.yml.template"), "flow_type: resume-run\n").unwrap();
270        std::fs::write(jobs_dir.join("hypertune.yml.template"), "flow_type: tune\n").unwrap();
271        std::fs::write(jobs_dir.join("train_act.yml.template"), "flow_type: train\n").unwrap();
272        std::fs::write(
273            jobs_dir.join("resume-train-act.yml.template"),
274            "flow_type: resume-run\n",
275        )
276        .unwrap();
277        std::fs::write(
278            assets_root.path().join("rl/requirements-rl.txt.template"),
279            "mecha10-rl-task-runner\n",
280        )
281        .unwrap();
282
283        let service = ProjectTemplateService::new();
284
285        service
286            .create_rl_training_bundle(temp.path(), assets_root.path())
287            .await
288            .unwrap();
289
290        assert!(temp.path().join("configs/rl/jobs/train.yml").exists());
291        assert!(temp.path().join("requirements.txt").exists());
292        assert!(!temp.path().join("kestra").exists());
293    }
294
295    #[tokio::test]
296    async fn create_dataset_build_job_writes_the_starter_job_outside_the_rl_train_catalog() {
297        let temp = TempDir::new().unwrap();
298        let assets_root = TempDir::new().unwrap();
299        let jobs_dir = assets_root.path().join("dataset/jobs");
300        std::fs::create_dir_all(&jobs_dir).unwrap();
301        std::fs::write(
302            jobs_dir.join("build_dataset.yml.template"),
303            "name: build-dataset\nflow_type: build-dataset\n",
304        )
305        .unwrap();
306
307        let service = ProjectTemplateService::new();
308
309        let written = service
310            .create_dataset_build_job(temp.path(), assets_root.path())
311            .await
312            .unwrap();
313
314        assert!(written);
315        assert!(temp.path().join("configs/datasets/jobs/build-dataset.yml").exists());
316        assert!(!temp.path().join("configs/rl/jobs").exists());
317    }
318}