mecha10_cli/services/
template_download.rs

1//! Template download service
2//!
3//! Downloads project templates from GitHub releases on-demand.
4//! Templates are versioned and cached locally for offline use.
5
6use anyhow::{Context, Result};
7use flate2::read::GzDecoder;
8use std::fs;
9use std::path::{Path, PathBuf};
10use tar::Archive;
11
12/// GitHub repository for template downloads
13const GITHUB_REPO: &str = "mecha-industries/user-tools";
14
15/// Service for downloading and caching project templates from GitHub
16pub struct TemplateDownloadService {
17    /// Cache directory for templates (~/.mecha10/templates/)
18    cache_dir: PathBuf,
19}
20
21impl TemplateDownloadService {
22    /// Create a new TemplateDownloadService with default cache directory
23    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    /// Create a TemplateService with a custom cache directory (for testing)
33    #[allow(dead_code)]
34    pub fn with_cache_dir(cache_dir: PathBuf) -> Self {
35        Self { cache_dir }
36    }
37
38    /// Get the latest template version from GitHub releases
39    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        // Strip 'v' prefix if present
68        Ok(tag_name.trim_start_matches('v').to_string())
69    }
70
71    /// Get the path to cached templates for a specific version
72    pub fn get_cached_path(&self, version: &str) -> PathBuf {
73        self.cache_dir.join(format!("v{}", version))
74    }
75
76    /// Check if templates for a specific version are cached
77    #[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    /// Ensure templates are available for a specific version
84    ///
85    /// Downloads and caches templates if not already present.
86    /// Returns the path to the templates directory.
87    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    /// Download templates for a specific version
103    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        // Create cache directory
120        fs::create_dir_all(cache_path).context("Failed to create template cache directory")?;
121
122        // Extract tarball
123        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    /// Read a template file from the cached templates
136    #[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    /// Read a binary template file from the cached templates
145    #[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    /// List all files in a template directory
154    #[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    /// Copy a template directory to the project
173    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    /// Copy a single template file to the project
188    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        // Ensure destination directory exists
198        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
214/// Recursively copy a directory
215fn 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}