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::{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}
42
43impl ProjectTemplateService {
44    /// Create a new ProjectTemplateService
45    pub fn new() -> Self {
46        Self {
47            rust: RustTemplates::new(),
48            infra: InfraTemplates::new(),
49            meta: MetaTemplates::new(),
50            rl_bundle: RlBundleTemplates::new(),
51        }
52    }
53
54    delegate_create!(
55        /// Create README.md for the project, reading the template from the freshly-fetched
56        /// `mecha10-assets` extraction at `assets_root`.
57        create_readme(meta, project_name, assets_root)
58    );
59
60    delegate_create!(
61        /// Create .gitignore for the project, reading the template from the freshly-fetched
62        /// `mecha10-assets` extraction at `assets_root`.
63        create_gitignore(meta, assets_root)
64    );
65
66    delegate_create!(
67        /// Create Cargo.toml for the project, reading the template from the freshly-fetched
68        /// `mecha10-assets` extraction at `assets_root`.
69        create_cargo_toml(rust, project_name, assets_root)
70    );
71
72    delegate_create!(
73        /// Create src/main.rs for the project, reading the template from the freshly-fetched
74        /// `mecha10-assets` extraction at `assets_root`.
75        create_main_rs(rust, project_name, assets_root)
76    );
77
78    delegate_create!(
79        /// Create build.rs for the project, reading the template from the freshly-fetched
80        /// `mecha10-assets` extraction at `assets_root`.
81        ///
82        /// Generates node_registry.rs, the node dispatcher based on mecha10.json.
83        create_build_rs(rust, assets_root)
84    );
85
86    delegate_create!(
87        /// Create .env.example for the project, reading the template from the freshly-fetched
88        /// `mecha10-assets` extraction at `assets_root`.
89        create_env_example(infra, project_name, assets_root)
90    );
91
92    /// Create rustfmt.toml for the project, reading the template from the freshly-fetched
93    /// `mecha10-assets` extraction at `assets_root`.
94    pub async fn create_rustfmt_toml(&self, path: &Path, assets_root: &Path) -> Result<()> {
95        self.rust.create_rustfmt_toml(path, assets_root).await
96    }
97
98    delegate_create!(
99        /// Create docker-compose.yml for the project with Redis and optional PostgreSQL
100        /// services, reading the template from the freshly-fetched `mecha10-assets` extraction
101        /// at `assets_root`.
102        create_docker_compose(infra, project_name, assets_root)
103    );
104
105    delegate_create!(
106        /// Create docker-compose.remote.yml and Dockerfile.remote for AI/ML nodes configured
107        /// in targets.remote in mecha10.json, reading the templates from the freshly-fetched
108        /// `mecha10-assets` extraction at `assets_root`.
109        create_remote_docker_files(infra, assets_root)
110    );
111
112    delegate_create!(
113        /// Create docker-compose.rl.yml, Dockerfile.rl, and entrypoint-rl.sh for running a
114        /// trained RL policy in production, reading the templates from the freshly-fetched
115        /// `mecha10-assets` extraction at `assets_root`.
116        create_rl_docker_files(infra, assets_root)
117    );
118
119    delegate_create!(
120        /// Create package.json for npm dependencies (requires npm install), reading the
121        /// template from the freshly-fetched `mecha10-assets` extraction at `assets_root`.
122        create_package_json(meta, project_name, assets_root)
123    );
124
125    delegate_create!(
126        /// Create requirements.txt for Python dependencies needed for AI nodes, reading the
127        /// template from the freshly-fetched `mecha10-assets` extraction at `assets_root`.
128        create_requirements_txt(meta, assets_root)
129    );
130
131    delegate_create!(
132        /// Create `.python-version`, pinning the Python version `mecha10 setup` should target,
133        /// reading the template from the freshly-fetched `mecha10-assets` extraction at
134        /// `assets_root`.
135        create_python_version(meta, assets_root)
136    );
137
138    /// Create the standard RL training bundle for a `rover` template project, reading its job
139    /// templates and Python requirements from the freshly-fetched `mecha10-assets` extraction at
140    /// `assets_root`.
141    ///
142    /// Callers should invoke `create_requirements_txt` first — this appends to that file rather
143    pub async fn create_rl_training_bundle(&self, path: &Path, assets_root: &Path) -> Result<()> {
144        self.rl_bundle.append_rl_requirements(path, assets_root).await?;
145        self.rl_bundle.create_rl_jobs(path, assets_root).await?;
146        Ok(())
147    }
148}
149
150impl Default for ProjectTemplateService {
151    fn default() -> Self {
152        Self::new()
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use tempfile::TempDir;
160
161    /// Stage a fake extracted `mecha10-assets` root containing a single `project/<name>`
162    /// template file, mirroring the real `mecha10-assets` layout.
163    fn stage_assets_root_with(name: &str, content: &str) -> TempDir {
164        let assets_root = TempDir::new().unwrap();
165        let project_dir = assets_root.path().join("project");
166        std::fs::create_dir_all(&project_dir).unwrap();
167        std::fs::write(project_dir.join(name), content).unwrap();
168        assets_root
169    }
170
171    #[tokio::test]
172    async fn create_readme_replaces_project_name_placeholder() {
173        let temp = TempDir::new().unwrap();
174        let assets_root = stage_assets_root_with("README.md.template", "# {{project_name}}\n");
175        let service = ProjectTemplateService::new();
176
177        service
178            .create_readme(temp.path(), "my-robot", assets_root.path())
179            .await
180            .unwrap();
181
182        let content = tokio::fs::read_to_string(temp.path().join("README.md")).await.unwrap();
183        assert!(content.contains("my-robot"));
184        assert!(!content.contains("{{project_name}}"));
185    }
186
187    #[tokio::test]
188    async fn create_gitignore_writes_file() {
189        let temp = TempDir::new().unwrap();
190        let assets_root = stage_assets_root_with(".gitignore.template", "target/\n");
191        let service = ProjectTemplateService::new();
192
193        service.create_gitignore(temp.path(), assets_root.path()).await.unwrap();
194
195        assert!(temp.path().join(".gitignore").exists());
196    }
197
198    #[tokio::test]
199    async fn create_python_version_writes_a_pin_mecha10_setup_can_read() {
200        let temp = TempDir::new().unwrap();
201        let assets_root = stage_assets_root_with(".python-version.template", "3.11\n");
202        let service = ProjectTemplateService::new();
203
204        service
205            .create_python_version(temp.path(), assets_root.path())
206            .await
207            .unwrap();
208
209        let content = tokio::fs::read_to_string(temp.path().join(".python-version"))
210            .await
211            .unwrap();
212        let mut parts = content.trim().split('.');
213        assert!(parts.next().unwrap().parse::<u32>().is_ok());
214        assert!(parts.next().unwrap().parse::<u32>().is_ok());
215        assert!(parts.next().is_none());
216    }
217
218    #[tokio::test]
219    async fn create_cargo_toml_includes_project_name() {
220        let temp = TempDir::new().unwrap();
221        let assets_root =
222            stage_assets_root_with("Cargo.toml.prod.template", "[package]\nname = \"{{project_name}}\"\n");
223        let service = ProjectTemplateService::new();
224
225        service
226            .create_cargo_toml(temp.path(), "my-robot", assets_root.path())
227            .await
228            .unwrap();
229
230        let content = tokio::fs::read_to_string(temp.path().join("Cargo.toml")).await.unwrap();
231        assert!(content.contains("my-robot"));
232    }
233
234    #[tokio::test]
235    async fn create_main_rs_writes_file() {
236        let temp = TempDir::new().unwrap();
237        let assets_root = stage_assets_root_with("main.rs.template", "fn main() {}\n");
238        let service = ProjectTemplateService::new();
239
240        service
241            .create_main_rs(temp.path(), "my-robot", assets_root.path())
242            .await
243            .unwrap();
244
245        assert!(temp.path().join("src/main.rs").exists());
246    }
247
248    #[tokio::test]
249    async fn create_rl_training_bundle_requires_requirements_txt_created_first() {
250        let temp = TempDir::new().unwrap();
251        let assets_root = TempDir::new().unwrap();
252        let jobs_dir = assets_root.path().join("rl/jobs");
253        std::fs::create_dir_all(&jobs_dir).unwrap();
254        std::fs::write(jobs_dir.join("train.yml.template"), "flow_type: train\n").unwrap();
255        std::fs::write(
256            jobs_dir.join("train-from-checkpoint.yml.template"),
257            "flow_type: resume-run\n",
258        )
259        .unwrap();
260        std::fs::write(jobs_dir.join("hypertune.yml.template"), "flow_type: tune\n").unwrap();
261        std::fs::write(
262            assets_root.path().join("rl/requirements-rl.txt.template"),
263            "mecha10-rl-task-runner\n",
264        )
265        .unwrap();
266
267        let service = ProjectTemplateService::new();
268
269        service
270            .create_rl_training_bundle(temp.path(), assets_root.path())
271            .await
272            .unwrap();
273
274        assert!(temp.path().join("configs/rl/jobs/train.yml").exists());
275        assert!(temp.path().join("requirements.txt").exists());
276        assert!(!temp.path().join("kestra").exists());
277    }
278}