Skip to main content

mecha10_templates/services/
template_download.rs

1//! Template download service
2//!
3//! Downloads project templates via the public `mecha10-api` downloads proxy
4//! (`GET /api/downloads/templates`), never a distribution point directly — same pattern as
5//! `mecha10-deploy`'s `BinaryManager`. Templates are versioned and cached locally for offline
6//! use. The templates tarball also carries the bundled simulation asset catalog
7//! (`models`/`environments`/`tasks`, LAB-2019) - there is no separate simulation-assets
8//! download/cache service anymore (the former `packages/nodes/simulator`'s
9//! `SimulationAssetManager` was removed).
10
11use anyhow::{Context, Result};
12use flate2::read::GzDecoder;
13use mecha10_cli_core::paths::urls::mecha10_api_base_url;
14use mecha10_cli_core::paths::user::templates_dir;
15use mecha10_cli_core::utils::build_client;
16use serde::Deserialize;
17use std::fs;
18use std::path::{Path, PathBuf};
19use tar::Archive;
20
21#[derive(Debug, Deserialize)]
22struct VersionResponse {
23    version: String,
24}
25
26pub const DEV_VERSION: &str = "dev";
27
28pub struct TemplateDownloadService {
29    cache_dir: PathBuf,
30}
31
32impl TemplateDownloadService {
33    /// Create a new TemplateDownloadService with default cache directory
34    pub fn new() -> Self {
35        Self {
36            cache_dir: templates_dir(),
37        }
38    }
39
40    /// Create a TemplateDownloadService with a custom cache directory (for testing)
41    #[allow(dead_code)]
42    pub fn with_cache_dir(cache_dir: PathBuf) -> Self {
43        Self { cache_dir }
44    }
45
46    /// Get the latest template version via `mecha10-api`
47    ///
48    /// Calls `GET /api/downloads/templates/version`, which resolves the version
49    /// without downloading the tarball.
50    pub async fn get_latest_version(&self) -> Result<String> {
51        let url = format!("{}/downloads/templates/version", mecha10_api_base_url());
52
53        let client = build_client()?;
54        let response = client
55            .get(&url)
56            .send()
57            .await
58            .context("Failed to fetch latest templates version from mecha10-api")?;
59
60        if !response.status().is_success() {
61            anyhow::bail!(
62                "Failed to get latest templates version: HTTP {}\nURL: {}",
63                response.status(),
64                url
65            );
66        }
67
68        let parsed: VersionResponse = response
69            .json()
70            .await
71            .context("Failed to parse version response from mecha10-api")?;
72        Ok(parsed.version)
73    }
74
75    /// Get the path to cached templates for a specific version
76    pub fn get_cached_path(&self, version: &str) -> PathBuf {
77        self.cache_dir.join(format!("v{}", version))
78    }
79
80    /// Check if templates for a specific version are cached
81    pub fn is_cached(&self, version: &str) -> bool {
82        let cache_path = self.get_cached_path(version);
83        cache_path.exists() && cache_path.join("templates").exists()
84    }
85
86    /// Resolve which template version to use.
87    ///
88    /// Prefers a locally `--install`ed dev build ([`DEV_VERSION`]) if one is
89    /// cached, so `scripts/release/index.py --component templates --install`
90    /// lets you iterate on template changes with `mecha10 init` fully
91    /// offline - no publish, no `MECHA10_API_URL` pointed at a local
92    /// mecha10-api. Falls back to [`Self::get_latest_version`] (a network
93    /// call) when no dev build is installed.
94    pub async fn resolve_version(&self) -> Result<String> {
95        if self.is_cached(DEV_VERSION) {
96            tracing::info!("Using locally installed dev templates (v{DEV_VERSION}) - run `mecha10 init` after a real publish to go back to the latest published templates");
97            return Ok(DEV_VERSION.to_string());
98        }
99
100        self.get_latest_version().await
101    }
102
103    /// Ensure templates are available for a specific version
104    ///
105    /// Downloads and caches templates if not already present.
106    /// Returns the path to the templates directory.
107    pub async fn ensure_templates(&self, version: &str) -> Result<PathBuf> {
108        let cache_path = self.get_cached_path(version);
109        let templates_path = cache_path.join("templates");
110
111        if templates_path.exists() {
112            tracing::debug!("Using cached templates v{}", version);
113            return Ok(templates_path);
114        }
115
116        tracing::info!("Downloading templates v{}...", version);
117        self.download_templates(version, &cache_path).await?;
118
119        Ok(templates_path)
120    }
121
122    /// Download templates for a specific version via `mecha10-api`
123    async fn download_templates(&self, version: &str, cache_path: &Path) -> Result<()> {
124        let url = format!("{}/downloads/templates?version={}", mecha10_api_base_url(), version);
125
126        let client = build_client()?;
127
128        let response = client
129            .get(&url)
130            .send()
131            .await
132            .context("Failed to connect to mecha10-api for templates")?;
133
134        if !response.status().is_success() {
135            anyhow::bail!("Failed to download templates: HTTP {}\nURL: {}", response.status(), url);
136        }
137
138        let bytes = response.bytes().await.context("Failed to read template archive")?;
139
140        fs::create_dir_all(cache_path).context("Failed to create template cache directory")?;
141
142        let decoder = GzDecoder::new(&bytes[..]);
143        let mut archive = Archive::new(decoder);
144
145        archive
146            .unpack(cache_path)
147            .context("Failed to extract template archive")?;
148
149        tracing::info!("Templates v{} cached at {:?}", version, cache_path);
150
151        Ok(())
152    }
153
154    /// Resolve `relative_path` against the cached `templates/` directory for `version`.
155    ///
156    /// Every method below needs this same `<cache_dir>/v<version>/templates/<relative_path>`
157    /// join; centralized so cargo-dupes' `read_template`/`read_template_bytes` finding
158    /// (LAB-2749) can't recur as more `templates/`-relative accessors are added here.
159    fn resolve_template_path(&self, version: &str, relative_path: &str) -> PathBuf {
160        self.get_cached_path(version).join("templates").join(relative_path)
161    }
162
163    /// Read a template file from the cached templates as UTF-8 text.
164    ///
165    /// Built on [`Self::read_template_bytes`] rather than a parallel
166    /// `resolve_template_path` + `fs::read_to_string` body: the two were byte-for-byte
167    /// identical in shape (cargo-dupes exact-duplicate group, LAB-2490) once
168    /// `resolve_template_path` was factored out, since the only real difference is
169    /// text-vs-bytes decoding, which layering on top of the bytes variant expresses
170    /// directly instead of duplicating the read.
171    #[allow(dead_code)]
172    pub fn read_template(&self, version: &str, relative_path: &str) -> Result<String> {
173        let bytes = self.read_template_bytes(version, relative_path)?;
174
175        String::from_utf8(bytes).with_context(|| {
176            format!(
177                "Template is not valid UTF-8: {:?}",
178                self.resolve_template_path(version, relative_path)
179            )
180        })
181    }
182
183    /// Read a binary template file from the cached templates
184    #[allow(dead_code)]
185    pub fn read_template_bytes(&self, version: &str, relative_path: &str) -> Result<Vec<u8>> {
186        let file_path = self.resolve_template_path(version, relative_path);
187
188        fs::read(&file_path).with_context(|| format!("Failed to read template: {:?}", file_path))
189    }
190
191    /// List all files in a template directory
192    #[allow(dead_code)]
193    pub fn list_template_dir(&self, version: &str, relative_path: &str) -> Result<Vec<PathBuf>> {
194        let dir_path = self.resolve_template_path(version, relative_path);
195
196        if !dir_path.exists() {
197            return Ok(Vec::new());
198        }
199
200        let mut files = Vec::new();
201        for entry in fs::read_dir(&dir_path)? {
202            let entry = entry?;
203            files.push(entry.path());
204        }
205
206        Ok(files)
207    }
208
209    /// Copy a template directory to the project
210    pub fn copy_template_dir(&self, version: &str, template_relative_path: &str, dest_path: &Path) -> Result<()> {
211        let src_path = self.resolve_template_path(version, template_relative_path);
212
213        if !src_path.exists() {
214            tracing::debug!("Template directory not found: {:?}", src_path);
215            return Ok(());
216        }
217
218        copy_dir_recursive(&src_path, dest_path)?;
219
220        Ok(())
221    }
222
223    /// Copy a single template file to the project
224    pub fn copy_template_file(&self, version: &str, template_relative_path: &str, dest_path: &Path) -> Result<()> {
225        let src_path = self.resolve_template_path(version, template_relative_path);
226
227        if !src_path.exists() {
228            tracing::debug!("Template file not found: {:?}", src_path);
229            return Ok(());
230        }
231
232        if let Some(parent) = dest_path.parent() {
233            fs::create_dir_all(parent)?;
234        }
235
236        fs::copy(&src_path, dest_path)?;
237
238        Ok(())
239    }
240}
241
242impl Default for TemplateDownloadService {
243    fn default() -> Self {
244        Self::new()
245    }
246}
247
248/// Skip macOS AppleDouble/resource-fork junk (`._*`, `.DS_Store`) that can leak into
249/// release tarballs when packaged on a Mac without `COPYFILE_DISABLE=1`.
250fn is_junk_entry(file_name: &std::ffi::OsStr) -> bool {
251    let name = file_name.to_string_lossy();
252    name.starts_with("._") || name == ".DS_Store"
253}
254
255/// Recursively copy a directory
256fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
257    fs::create_dir_all(dst)?;
258
259    for entry in fs::read_dir(src)? {
260        let entry = entry?;
261        if is_junk_entry(&entry.file_name()) {
262            continue;
263        }
264        let src_path = entry.path();
265        let dst_path = dst.join(entry.file_name());
266
267        if src_path.is_dir() {
268            copy_dir_recursive(&src_path, &dst_path)?;
269        } else {
270            fs::copy(&src_path, &dst_path)?;
271        }
272    }
273
274    Ok(())
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn test_cache_path() {
283        let service = TemplateDownloadService::with_cache_dir(PathBuf::from("/tmp/test"));
284        assert_eq!(service.get_cached_path("0.1.14"), PathBuf::from("/tmp/test/v0.1.14"));
285    }
286
287    #[test]
288    fn default_cache_dir_matches_paths_user_templates_dir() {
289        let service = TemplateDownloadService::new();
290        assert_eq!(service.cache_dir, templates_dir());
291    }
292
293    #[test]
294    fn dev_version_path_matches_install_py_layout() {
295        let service = TemplateDownloadService::with_cache_dir(PathBuf::from("/tmp/test"));
296        assert_eq!(service.get_cached_path(DEV_VERSION), PathBuf::from("/tmp/test/vdev"));
297    }
298
299    #[tokio::test]
300    async fn resolve_version_prefers_locally_installed_dev_build_without_network() {
301        let tmp = tempfile::tempdir().unwrap();
302        let dev_templates_dir = tmp.path().join(format!("v{DEV_VERSION}")).join("templates");
303        fs::create_dir_all(&dev_templates_dir).unwrap();
304
305        let service = TemplateDownloadService::with_cache_dir(tmp.path().to_path_buf());
306        let version = service.resolve_version().await.unwrap();
307        assert_eq!(version, DEV_VERSION);
308    }
309
310    /// Wave 5 / Wave 10: `copy_template_dir` copies an entire directory, not an enumerated list
311    /// of filenames - this is the exact mechanism `mecha10 add`'s `copy_node_configs` and
312    /// `mecha10 init`'s `copy_downloaded_templates` both rely on. As of wave 10, a framework
313    /// node's `<name>.schema.json` is staged by `scripts/release/build/package-templates.sh`
314    /// under its own `nodes/@mecha10/<name>/` template subtree, separate from
315    /// `configs/nodes/@mecha10/<name>/` (which carries only `config.json`) - so bringing both
316    /// into a scaffolded project now takes two `copy_template_dir` calls, one per subtree, with
317    /// no per-file-name copy step needed for either. This proves that mechanism directly against
318    /// a fixture that mirrors the real archive layout, rather than assuming it from reading the
319    /// code.
320    #[test]
321    fn copy_template_dir_brings_every_file_along_for_both_config_and_schema_subtrees() {
322        let cache = tempfile::tempdir().unwrap();
323        let version = "1.2.3";
324        let staged_templates_dir = cache.path().join(format!("v{version}")).join("templates");
325
326        let config_template_dir = staged_templates_dir.join("configs/nodes/@mecha10/camera");
327        fs::create_dir_all(&config_template_dir).unwrap();
328        fs::write(
329            config_template_dir.join("config.json"),
330            r#"{"dev": {}, "production": {}}"#,
331        )
332        .unwrap();
333
334        let schema_template_dir = staged_templates_dir.join("nodes/@mecha10/camera");
335        fs::create_dir_all(&schema_template_dir).unwrap();
336        fs::write(schema_template_dir.join("camera.schema.json"), r#"{"type": "object"}"#).unwrap();
337
338        let service = TemplateDownloadService::with_cache_dir(cache.path().to_path_buf());
339        let dest = tempfile::tempdir().unwrap();
340
341        let config_dest_dir = dest.path().join("configs/nodes/@mecha10/camera");
342        service
343            .copy_template_dir(version, "configs/nodes/@mecha10/camera", &config_dest_dir)
344            .unwrap();
345        assert!(config_dest_dir.join("config.json").exists());
346        assert!(
347            !config_dest_dir.join("camera.schema.json").exists(),
348            "the schema file must not be staged alongside config.json anymore"
349        );
350
351        let schema_dest_dir = dest.path().join("nodes/@mecha10/camera");
352        service
353            .copy_template_dir(version, "nodes/@mecha10/camera", &schema_dest_dir)
354            .unwrap();
355        assert!(
356            schema_dest_dir.join("camera.schema.json").exists(),
357            "whole-directory copy must bring the schema file along without any dedicated code for it"
358        );
359        assert_eq!(
360            fs::read_to_string(schema_dest_dir.join("camera.schema.json")).unwrap(),
361            r#"{"type": "object"}"#
362        );
363    }
364}