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