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/// Mirrors `mecha10-api`'s `catalog::downloads::types::VersionResponse`.
22#[derive(Debug, Deserialize)]
23struct VersionResponse {
24    version: String,
25}
26
27/// Version string used for templates installed locally via
28/// `scripts/release/index.py --component templates --install` (see
29/// `scripts/release/install.py::install_templates`).
30///
31/// Never a valid published semver, so a `--install`ed build can never collide
32/// with (or be silently overwritten by) a real release, and `resolve_version`
33/// can detect it purely from the filesystem without hitting the network.
34pub const DEV_VERSION: &str = "dev";
35
36/// Service for downloading and caching project templates via `mecha10-api`
37pub struct TemplateDownloadService {
38    /// Cache directory for templates (~/.mecha10/templates/)
39    cache_dir: PathBuf,
40}
41
42impl TemplateDownloadService {
43    /// Create a new TemplateDownloadService with default cache directory
44    pub fn new() -> Self {
45        Self {
46            cache_dir: templates_dir(),
47        }
48    }
49
50    /// Create a TemplateDownloadService with a custom cache directory (for testing)
51    #[allow(dead_code)]
52    pub fn with_cache_dir(cache_dir: PathBuf) -> Self {
53        Self { cache_dir }
54    }
55
56    /// Get the latest template version via `mecha10-api`
57    ///
58    /// Calls `GET /api/downloads/templates/version`, which resolves the version
59    /// without downloading the tarball.
60    pub async fn get_latest_version(&self) -> Result<String> {
61        let url = format!("{}/downloads/templates/version", mecha10_api_base_url());
62
63        let client = build_client()?;
64        let response = client
65            .get(&url)
66            .send()
67            .await
68            .context("Failed to fetch latest templates version from mecha10-api")?;
69
70        if !response.status().is_success() {
71            anyhow::bail!(
72                "Failed to get latest templates version: HTTP {}\nURL: {}",
73                response.status(),
74                url
75            );
76        }
77
78        let parsed: VersionResponse = response
79            .json()
80            .await
81            .context("Failed to parse version response from mecha10-api")?;
82        Ok(parsed.version)
83    }
84
85    /// Get the path to cached templates for a specific version
86    pub fn get_cached_path(&self, version: &str) -> PathBuf {
87        self.cache_dir.join(format!("v{}", version))
88    }
89
90    /// Check if templates for a specific version are cached
91    pub fn is_cached(&self, version: &str) -> bool {
92        let cache_path = self.get_cached_path(version);
93        cache_path.exists() && cache_path.join("templates").exists()
94    }
95
96    /// Resolve which template version to use.
97    ///
98    /// Prefers a locally `--install`ed dev build ([`DEV_VERSION`]) if one is
99    /// cached, so `scripts/release/index.py --component templates --install`
100    /// lets you iterate on template changes with `mecha10 init` fully
101    /// offline - no publish, no `MECHA10_API_URL` pointed at a local
102    /// mecha10-api. Falls back to [`Self::get_latest_version`] (a network
103    /// call) when no dev build is installed.
104    pub async fn resolve_version(&self) -> Result<String> {
105        if self.is_cached(DEV_VERSION) {
106            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");
107            return Ok(DEV_VERSION.to_string());
108        }
109
110        self.get_latest_version().await
111    }
112
113    /// Ensure templates are available for a specific version
114    ///
115    /// Downloads and caches templates if not already present.
116    /// Returns the path to the templates directory.
117    pub async fn ensure_templates(&self, version: &str) -> Result<PathBuf> {
118        let cache_path = self.get_cached_path(version);
119        let templates_path = cache_path.join("templates");
120
121        if templates_path.exists() {
122            tracing::debug!("Using cached templates v{}", version);
123            return Ok(templates_path);
124        }
125
126        tracing::info!("Downloading templates v{}...", version);
127        self.download_templates(version, &cache_path).await?;
128
129        Ok(templates_path)
130    }
131
132    /// Download templates for a specific version via `mecha10-api`
133    async fn download_templates(&self, version: &str, cache_path: &Path) -> Result<()> {
134        let url = format!("{}/downloads/templates?version={}", mecha10_api_base_url(), version);
135
136        let client = build_client()?;
137
138        let response = client
139            .get(&url)
140            .send()
141            .await
142            .context("Failed to connect to mecha10-api for templates")?;
143
144        if !response.status().is_success() {
145            anyhow::bail!("Failed to download templates: HTTP {}\nURL: {}", response.status(), url);
146        }
147
148        let bytes = response.bytes().await.context("Failed to read template archive")?;
149
150        fs::create_dir_all(cache_path).context("Failed to create template cache directory")?;
151
152        let decoder = GzDecoder::new(&bytes[..]);
153        let mut archive = Archive::new(decoder);
154
155        archive
156            .unpack(cache_path)
157            .context("Failed to extract template archive")?;
158
159        tracing::info!("Templates v{} cached at {:?}", version, cache_path);
160
161        Ok(())
162    }
163
164    /// Resolve `relative_path` against the cached `templates/` directory for `version`.
165    ///
166    /// Every method below needs this same `<cache_dir>/v<version>/templates/<relative_path>`
167    /// join; centralized so cargo-dupes' `read_template`/`read_template_bytes` finding
168    /// (LAB-2749) can't recur as more `templates/`-relative accessors are added here.
169    fn resolve_template_path(&self, version: &str, relative_path: &str) -> PathBuf {
170        self.get_cached_path(version).join("templates").join(relative_path)
171    }
172
173    /// Read a template file from the cached templates as UTF-8 text.
174    ///
175    /// Built on [`Self::read_template_bytes`] rather than a parallel
176    /// `resolve_template_path` + `fs::read_to_string` body: the two were byte-for-byte
177    /// identical in shape (cargo-dupes exact-duplicate group, LAB-2490) once
178    /// `resolve_template_path` was factored out, since the only real difference is
179    /// text-vs-bytes decoding, which layering on top of the bytes variant expresses
180    /// directly instead of duplicating the read.
181    #[allow(dead_code)]
182    pub fn read_template(&self, version: &str, relative_path: &str) -> Result<String> {
183        let bytes = self.read_template_bytes(version, relative_path)?;
184
185        String::from_utf8(bytes).with_context(|| {
186            format!(
187                "Template is not valid UTF-8: {:?}",
188                self.resolve_template_path(version, relative_path)
189            )
190        })
191    }
192
193    /// Read a binary template file from the cached templates
194    #[allow(dead_code)]
195    pub fn read_template_bytes(&self, version: &str, relative_path: &str) -> Result<Vec<u8>> {
196        let file_path = self.resolve_template_path(version, relative_path);
197
198        fs::read(&file_path).with_context(|| format!("Failed to read template: {:?}", file_path))
199    }
200
201    /// List all files in a template directory
202    #[allow(dead_code)]
203    pub fn list_template_dir(&self, version: &str, relative_path: &str) -> Result<Vec<PathBuf>> {
204        let dir_path = self.resolve_template_path(version, relative_path);
205
206        if !dir_path.exists() {
207            return Ok(Vec::new());
208        }
209
210        let mut files = Vec::new();
211        for entry in fs::read_dir(&dir_path)? {
212            let entry = entry?;
213            files.push(entry.path());
214        }
215
216        Ok(files)
217    }
218
219    /// Copy a template directory to the project
220    pub fn copy_template_dir(&self, version: &str, template_relative_path: &str, dest_path: &Path) -> Result<()> {
221        let src_path = self.resolve_template_path(version, template_relative_path);
222
223        if !src_path.exists() {
224            tracing::debug!("Template directory not found: {:?}", src_path);
225            return Ok(());
226        }
227
228        copy_dir_recursive(&src_path, dest_path)?;
229
230        Ok(())
231    }
232
233    /// Copy a single template file to the project
234    pub fn copy_template_file(&self, version: &str, template_relative_path: &str, dest_path: &Path) -> Result<()> {
235        let src_path = self.resolve_template_path(version, template_relative_path);
236
237        if !src_path.exists() {
238            tracing::debug!("Template file not found: {:?}", src_path);
239            return Ok(());
240        }
241
242        if let Some(parent) = dest_path.parent() {
243            fs::create_dir_all(parent)?;
244        }
245
246        fs::copy(&src_path, dest_path)?;
247
248        Ok(())
249    }
250}
251
252impl Default for TemplateDownloadService {
253    fn default() -> Self {
254        Self::new()
255    }
256}
257
258/// Recursively copy a directory
259fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
260    fs::create_dir_all(dst)?;
261
262    for entry in fs::read_dir(src)? {
263        let entry = entry?;
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}