mecha10_cli/services/
template_download.rs1use anyhow::{Context, Result};
7use flate2::read::GzDecoder;
8use std::fs;
9use std::path::{Path, PathBuf};
10use tar::Archive;
11
12const GITHUB_REPO: &str = "mecha-industries/user-tools";
14
15pub struct TemplateDownloadService {
17 cache_dir: PathBuf,
19}
20
21impl TemplateDownloadService {
22 pub fn new() -> Self {
24 let cache_dir = dirs::home_dir()
25 .unwrap_or_else(|| PathBuf::from("."))
26 .join(".mecha10")
27 .join("templates");
28
29 Self { cache_dir }
30 }
31
32 #[allow(dead_code)]
34 pub fn with_cache_dir(cache_dir: PathBuf) -> Self {
35 Self { cache_dir }
36 }
37
38 pub async fn get_latest_version(&self) -> Result<String> {
40 let url = format!("https://api.github.com/repos/{}/releases/latest", GITHUB_REPO);
41
42 let client = reqwest::Client::builder().user_agent("mecha10-cli").build()?;
43
44 let response = client
45 .get(&url)
46 .send()
47 .await
48 .context("Failed to fetch latest release from GitHub")?;
49
50 if !response.status().is_success() {
51 anyhow::bail!(
52 "GitHub API returned status {}: {}",
53 response.status(),
54 response.text().await.unwrap_or_default()
55 );
56 }
57
58 let release: serde_json::Value = response
59 .json()
60 .await
61 .context("Failed to parse GitHub release response")?;
62
63 let tag_name = release["tag_name"]
64 .as_str()
65 .context("No tag_name in release response")?;
66
67 Ok(tag_name.trim_start_matches('v').to_string())
69 }
70
71 pub fn get_cached_path(&self, version: &str) -> PathBuf {
73 self.cache_dir.join(format!("v{}", version))
74 }
75
76 #[allow(dead_code)]
78 pub fn is_cached(&self, version: &str) -> bool {
79 let cache_path = self.get_cached_path(version);
80 cache_path.exists() && cache_path.join("templates").exists()
81 }
82
83 pub async fn ensure_templates(&self, version: &str) -> Result<PathBuf> {
88 let cache_path = self.get_cached_path(version);
89 let templates_path = cache_path.join("templates");
90
91 if templates_path.exists() {
92 tracing::debug!("Using cached templates v{}", version);
93 return Ok(templates_path);
94 }
95
96 tracing::info!("Downloading templates v{}...", version);
97 self.download_templates(version, &cache_path).await?;
98
99 Ok(templates_path)
100 }
101
102 async fn download_templates(&self, version: &str, cache_path: &Path) -> Result<()> {
104 let url = format!(
105 "https://github.com/{}/releases/download/v{}/mecha10-v{}-templates.tar.gz",
106 GITHUB_REPO, version, version
107 );
108
109 let client = reqwest::Client::builder().user_agent("mecha10-cli").build()?;
110
111 let response = client.get(&url).send().await.context("Failed to download templates")?;
112
113 if !response.status().is_success() {
114 anyhow::bail!("Failed to download templates: HTTP {}", response.status());
115 }
116
117 let bytes = response.bytes().await.context("Failed to read template archive")?;
118
119 fs::create_dir_all(cache_path).context("Failed to create template cache directory")?;
121
122 let decoder = GzDecoder::new(&bytes[..]);
124 let mut archive = Archive::new(decoder);
125
126 archive
127 .unpack(cache_path)
128 .context("Failed to extract template archive")?;
129
130 tracing::info!("Templates v{} cached at {:?}", version, cache_path);
131
132 Ok(())
133 }
134
135 #[allow(dead_code)]
137 pub fn read_template(&self, version: &str, relative_path: &str) -> Result<String> {
138 let templates_path = self.get_cached_path(version).join("templates");
139 let file_path = templates_path.join(relative_path);
140
141 fs::read_to_string(&file_path).with_context(|| format!("Failed to read template: {:?}", file_path))
142 }
143
144 #[allow(dead_code)]
146 pub fn read_template_bytes(&self, version: &str, relative_path: &str) -> Result<Vec<u8>> {
147 let templates_path = self.get_cached_path(version).join("templates");
148 let file_path = templates_path.join(relative_path);
149
150 fs::read(&file_path).with_context(|| format!("Failed to read template: {:?}", file_path))
151 }
152
153 #[allow(dead_code)]
155 pub fn list_template_dir(&self, version: &str, relative_path: &str) -> Result<Vec<PathBuf>> {
156 let templates_path = self.get_cached_path(version).join("templates");
157 let dir_path = templates_path.join(relative_path);
158
159 if !dir_path.exists() {
160 return Ok(Vec::new());
161 }
162
163 let mut files = Vec::new();
164 for entry in fs::read_dir(&dir_path)? {
165 let entry = entry?;
166 files.push(entry.path());
167 }
168
169 Ok(files)
170 }
171
172 pub fn copy_template_dir(&self, version: &str, template_relative_path: &str, dest_path: &Path) -> Result<()> {
174 let templates_path = self.get_cached_path(version).join("templates");
175 let src_path = templates_path.join(template_relative_path);
176
177 if !src_path.exists() {
178 tracing::debug!("Template directory not found: {:?}", src_path);
179 return Ok(());
180 }
181
182 copy_dir_recursive(&src_path, dest_path)?;
183
184 Ok(())
185 }
186
187 pub fn copy_template_file(&self, version: &str, template_relative_path: &str, dest_path: &Path) -> Result<()> {
189 let templates_path = self.get_cached_path(version).join("templates");
190 let src_path = templates_path.join(template_relative_path);
191
192 if !src_path.exists() {
193 tracing::debug!("Template file not found: {:?}", src_path);
194 return Ok(());
195 }
196
197 if let Some(parent) = dest_path.parent() {
199 fs::create_dir_all(parent)?;
200 }
201
202 fs::copy(&src_path, dest_path)?;
203
204 Ok(())
205 }
206}
207
208impl Default for TemplateDownloadService {
209 fn default() -> Self {
210 Self::new()
211 }
212}
213
214fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
216 fs::create_dir_all(dst)?;
217
218 for entry in fs::read_dir(src)? {
219 let entry = entry?;
220 let src_path = entry.path();
221 let dst_path = dst.join(entry.file_name());
222
223 if src_path.is_dir() {
224 copy_dir_recursive(&src_path, &dst_path)?;
225 } else {
226 fs::copy(&src_path, &dst_path)?;
227 }
228 }
229
230 Ok(())
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 #[test]
238 fn test_cache_path() {
239 let service = TemplateDownloadService::with_cache_dir(PathBuf::from("/tmp/test"));
240 assert_eq!(service.get_cached_path("0.1.14"), PathBuf::from("/tmp/test/v0.1.14"));
241 }
242}