mecha10_templates/services/
template_download.rs1use 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 pub fn new() -> Self {
35 Self {
36 cache_dir: templates_dir(),
37 }
38 }
39
40 #[allow(dead_code)]
42 pub fn with_cache_dir(cache_dir: PathBuf) -> Self {
43 Self { cache_dir }
44 }
45
46 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 pub fn get_cached_path(&self, version: &str) -> PathBuf {
77 self.cache_dir.join(format!("v{}", version))
78 }
79
80 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 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 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 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 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 #[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 #[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 #[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 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 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
248fn 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
255fn 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 #[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}