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 serde::Deserialize;
16use std::fs;
17use std::path::{Path, PathBuf};
18use tar::Archive;
19
20/// Mirrors `mecha10-api`'s `catalog::downloads::types::VersionResponse`.
21#[derive(Debug, Deserialize)]
22struct VersionResponse {
23    version: String,
24}
25
26/// Version string used for templates installed locally via
27/// `scripts/release/index.py --component templates --install` (see
28/// `scripts/release/install.py::install_templates`).
29///
30/// Never a valid published semver, so a `--install`ed build can never collide
31/// with (or be silently overwritten by) a real release, and `resolve_version`
32/// can detect it purely from the filesystem without hitting the network.
33pub const DEV_VERSION: &str = "dev";
34
35/// Service for downloading and caching project templates via `mecha10-api`
36pub struct TemplateDownloadService {
37    /// Cache directory for templates (~/.mecha10/templates/)
38    cache_dir: PathBuf,
39}
40
41impl TemplateDownloadService {
42    /// Create a new TemplateDownloadService with default cache directory
43    pub fn new() -> Self {
44        Self {
45            cache_dir: templates_dir(),
46        }
47    }
48
49    /// Create a TemplateDownloadService with a custom cache directory (for testing)
50    #[allow(dead_code)]
51    pub fn with_cache_dir(cache_dir: PathBuf) -> Self {
52        Self { cache_dir }
53    }
54
55    /// Get the latest template version via `mecha10-api`
56    ///
57    /// Calls `GET /api/downloads/templates/version`, which resolves the version
58    /// without downloading the tarball.
59    pub async fn get_latest_version(&self) -> Result<String> {
60        let url = format!("{}/downloads/templates/version", mecha10_api_base_url());
61
62        let client = Self::build_client()?;
63        let response = client
64            .get(&url)
65            .send()
66            .await
67            .context("Failed to fetch latest templates version from mecha10-api")?;
68
69        if !response.status().is_success() {
70            anyhow::bail!(
71                "Failed to get latest templates version: HTTP {}\nURL: {}",
72                response.status(),
73                url
74            );
75        }
76
77        let parsed: VersionResponse = response
78            .json()
79            .await
80            .context("Failed to parse version response from mecha10-api")?;
81        Ok(parsed.version)
82    }
83
84    /// Build an HTTP client
85    fn build_client() -> Result<reqwest::Client> {
86        reqwest::Client::builder()
87            .user_agent("mecha10-cli")
88            .build()
89            .context("Failed to build HTTP client")
90    }
91
92    /// Get the path to cached templates for a specific version
93    pub fn get_cached_path(&self, version: &str) -> PathBuf {
94        self.cache_dir.join(format!("v{}", version))
95    }
96
97    /// Check if templates for a specific version are cached
98    pub fn is_cached(&self, version: &str) -> bool {
99        let cache_path = self.get_cached_path(version);
100        cache_path.exists() && cache_path.join("templates").exists()
101    }
102
103    /// Resolve which template version to use.
104    ///
105    /// Prefers a locally `--install`ed dev build ([`DEV_VERSION`]) if one is
106    /// cached, so `scripts/release/index.py --component templates --install`
107    /// lets you iterate on template changes with `mecha10 init` fully
108    /// offline - no publish, no `MECHA10_API_URL` pointed at a local
109    /// mecha10-api. Falls back to [`Self::get_latest_version`] (a network
110    /// call) when no dev build is installed.
111    pub async fn resolve_version(&self) -> Result<String> {
112        if self.is_cached(DEV_VERSION) {
113            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");
114            return Ok(DEV_VERSION.to_string());
115        }
116
117        self.get_latest_version().await
118    }
119
120    /// Ensure templates are available for a specific version
121    ///
122    /// Downloads and caches templates if not already present.
123    /// Returns the path to the templates directory.
124    pub async fn ensure_templates(&self, version: &str) -> Result<PathBuf> {
125        let cache_path = self.get_cached_path(version);
126        let templates_path = cache_path.join("templates");
127
128        if templates_path.exists() {
129            tracing::debug!("Using cached templates v{}", version);
130            return Ok(templates_path);
131        }
132
133        tracing::info!("Downloading templates v{}...", version);
134        self.download_templates(version, &cache_path).await?;
135
136        Ok(templates_path)
137    }
138
139    /// Download templates for a specific version via `mecha10-api`
140    async fn download_templates(&self, version: &str, cache_path: &Path) -> Result<()> {
141        let url = format!("{}/downloads/templates?version={}", mecha10_api_base_url(), version);
142
143        let client = Self::build_client()?;
144
145        let response = client
146            .get(&url)
147            .send()
148            .await
149            .context("Failed to connect to mecha10-api for templates")?;
150
151        if !response.status().is_success() {
152            anyhow::bail!("Failed to download templates: HTTP {}\nURL: {}", response.status(), url);
153        }
154
155        let bytes = response.bytes().await.context("Failed to read template archive")?;
156
157        // Create cache directory
158        fs::create_dir_all(cache_path).context("Failed to create template cache directory")?;
159
160        // Extract tarball
161        let decoder = GzDecoder::new(&bytes[..]);
162        let mut archive = Archive::new(decoder);
163
164        archive
165            .unpack(cache_path)
166            .context("Failed to extract template archive")?;
167
168        tracing::info!("Templates v{} cached at {:?}", version, cache_path);
169
170        Ok(())
171    }
172
173    /// Read a template file from the cached templates
174    #[allow(dead_code)]
175    pub fn read_template(&self, version: &str, relative_path: &str) -> Result<String> {
176        let templates_path = self.get_cached_path(version).join("templates");
177        let file_path = templates_path.join(relative_path);
178
179        fs::read_to_string(&file_path).with_context(|| format!("Failed to read template: {:?}", file_path))
180    }
181
182    /// Read a binary template file from the cached templates
183    #[allow(dead_code)]
184    pub fn read_template_bytes(&self, version: &str, relative_path: &str) -> Result<Vec<u8>> {
185        let templates_path = self.get_cached_path(version).join("templates");
186        let file_path = templates_path.join(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 templates_path = self.get_cached_path(version).join("templates");
195        let dir_path = templates_path.join(relative_path);
196
197        if !dir_path.exists() {
198            return Ok(Vec::new());
199        }
200
201        let mut files = Vec::new();
202        for entry in fs::read_dir(&dir_path)? {
203            let entry = entry?;
204            files.push(entry.path());
205        }
206
207        Ok(files)
208    }
209
210    /// Copy a template directory to the project
211    pub fn copy_template_dir(&self, version: &str, template_relative_path: &str, dest_path: &Path) -> Result<()> {
212        let templates_path = self.get_cached_path(version).join("templates");
213        let src_path = templates_path.join(template_relative_path);
214
215        if !src_path.exists() {
216            tracing::debug!("Template directory not found: {:?}", src_path);
217            return Ok(());
218        }
219
220        copy_dir_recursive(&src_path, dest_path)?;
221
222        Ok(())
223    }
224
225    /// Copy a single template file to the project
226    pub fn copy_template_file(&self, version: &str, template_relative_path: &str, dest_path: &Path) -> Result<()> {
227        let templates_path = self.get_cached_path(version).join("templates");
228        let src_path = templates_path.join(template_relative_path);
229
230        if !src_path.exists() {
231            tracing::debug!("Template file not found: {:?}", src_path);
232            return Ok(());
233        }
234
235        // Ensure destination directory exists
236        if let Some(parent) = dest_path.parent() {
237            fs::create_dir_all(parent)?;
238        }
239
240        fs::copy(&src_path, dest_path)?;
241
242        Ok(())
243    }
244}
245
246impl Default for TemplateDownloadService {
247    fn default() -> Self {
248        Self::new()
249    }
250}
251
252/// Recursively copy a directory
253fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
254    fs::create_dir_all(dst)?;
255
256    for entry in fs::read_dir(src)? {
257        let entry = entry?;
258        let src_path = entry.path();
259        let dst_path = dst.join(entry.file_name());
260
261        if src_path.is_dir() {
262            copy_dir_recursive(&src_path, &dst_path)?;
263        } else {
264            fs::copy(&src_path, &dst_path)?;
265        }
266    }
267
268    Ok(())
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn test_cache_path() {
277        let service = TemplateDownloadService::with_cache_dir(PathBuf::from("/tmp/test"));
278        assert_eq!(service.get_cached_path("0.1.14"), PathBuf::from("/tmp/test/v0.1.14"));
279    }
280
281    #[test]
282    fn default_cache_dir_matches_paths_user_templates_dir() {
283        // TemplateDownloadService::new()'s default cache_dir must stay byte-identical to
284        // `paths::user::templates_dir()` - it's the single source of truth for `~/.mecha10/*`
285        // paths (LAB-1981).
286        let service = TemplateDownloadService::new();
287        assert_eq!(service.cache_dir, templates_dir());
288    }
289
290    #[test]
291    fn dev_version_path_matches_install_py_layout() {
292        // scripts/release/install.py::install_templates extracts into
293        // `~/.mecha10/templates/v{DEV_VERSION}/templates/` - keep this in sync.
294        let service = TemplateDownloadService::with_cache_dir(PathBuf::from("/tmp/test"));
295        assert_eq!(service.get_cached_path(DEV_VERSION), PathBuf::from("/tmp/test/vdev"));
296    }
297
298    #[tokio::test]
299    async fn resolve_version_prefers_locally_installed_dev_build_without_network() {
300        let tmp = tempfile::tempdir().unwrap();
301        let dev_templates_dir = tmp.path().join(format!("v{DEV_VERSION}")).join("templates");
302        fs::create_dir_all(&dev_templates_dir).unwrap();
303
304        let service = TemplateDownloadService::with_cache_dir(tmp.path().to_path_buf());
305        // No network access happens here - if it did, this call would hang or
306        // fail in a sandboxed test environment instead of resolving instantly.
307        let version = service.resolve_version().await.unwrap();
308        assert_eq!(version, DEV_VERSION);
309    }
310}