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)]
23struct VersionResponse {
24 version: String,
25}
26
27pub const DEV_VERSION: &str = "dev";
35
36pub struct TemplateDownloadService {
38 cache_dir: PathBuf,
40}
41
42impl TemplateDownloadService {
43 pub fn new() -> Self {
45 Self {
46 cache_dir: templates_dir(),
47 }
48 }
49
50 #[allow(dead_code)]
52 pub fn with_cache_dir(cache_dir: PathBuf) -> Self {
53 Self { cache_dir }
54 }
55
56 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 pub fn get_cached_path(&self, version: &str) -> PathBuf {
87 self.cache_dir.join(format!("v{}", version))
88 }
89
90 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 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 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 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 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 #[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 #[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 #[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 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 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
258fn 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}