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 serde::Deserialize;
16use std::fs;
17use std::path::{Path, PathBuf};
18use tar::Archive;
19
20#[derive(Debug, Deserialize)]
22struct VersionResponse {
23 version: String,
24}
25
26pub const DEV_VERSION: &str = "dev";
34
35pub struct TemplateDownloadService {
37 cache_dir: PathBuf,
39}
40
41impl TemplateDownloadService {
42 pub fn new() -> Self {
44 Self {
45 cache_dir: templates_dir(),
46 }
47 }
48
49 #[allow(dead_code)]
51 pub fn with_cache_dir(cache_dir: PathBuf) -> Self {
52 Self { cache_dir }
53 }
54
55 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 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 pub fn get_cached_path(&self, version: &str) -> PathBuf {
94 self.cache_dir.join(format!("v{}", version))
95 }
96
97 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 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 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 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 fs::create_dir_all(cache_path).context("Failed to create template cache directory")?;
159
160 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 #[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 #[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 #[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 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 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 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
252fn 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 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 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 let version = service.resolve_version().await.unwrap();
308 assert_eq!(version, DEV_VERSION);
309 }
310}