mecha10_templates/services/
template_download.rs1use 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 pub fn new() -> Self {
30 Self {
31 cache_dir: templates_dir(),
32 }
33 }
34
35 #[allow(dead_code)]
37 pub fn with_cache_dir(cache_dir: PathBuf) -> Self {
38 Self { cache_dir }
39 }
40
41 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 pub fn get_cached_path(&self, version: &str) -> PathBuf {
71 self.cache_dir.join(format!("v{}", version))
72 }
73
74 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 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 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 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 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 #[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 #[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 #[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 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 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
229fn 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
236fn 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 #[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}