Skip to main content

ferrous_forge/templates/repository/
github.rs

1//! GitHub client for fetching templates
2//!
3//! @task T021
4//! @epic T014
5
6use crate::error::{Error, Result};
7use crate::templates::manifest::TemplateManifest;
8use crate::templates::repository::{CachedTemplate, TemplateRepository};
9use serde::Deserialize;
10use std::path::Path;
11use std::time::Duration;
12
13/// GitHub API client for template fetching
14pub struct GitHubClient {
15    client: reqwest::Client,
16    api_base: String,
17}
18
19/// GitHub repository reference
20#[derive(Debug, Clone)]
21pub struct RepoRef {
22    /// Repository owner
23    pub owner: String,
24    /// Repository name
25    pub repo: String,
26    /// Git reference (branch, tag, or commit)
27    pub git_ref: Option<String>,
28}
29
30/// GitHub repository information
31#[derive(Debug, Deserialize)]
32struct GitHubRepo {
33    #[allow(dead_code)]
34    id: u64,
35    #[allow(dead_code)]
36    name: String,
37    #[allow(dead_code)]
38    full_name: String,
39    #[allow(dead_code)]
40    description: Option<String>,
41    #[serde(rename = "stargazers_count")]
42    #[allow(dead_code)]
43    stars: u32,
44    #[serde(rename = "updated_at")]
45    #[allow(dead_code)]
46    updated_at: String,
47    default_branch: String,
48}
49
50/// GitHub tree entry
51#[derive(Debug, Deserialize)]
52struct TreeEntry {
53    path: String,
54    #[serde(rename = "type")]
55    entry_type: String,
56    #[allow(dead_code)]
57    sha: String,
58    #[allow(dead_code)]
59    size: Option<u64>,
60}
61
62/// GitHub tree response
63#[derive(Debug, Deserialize)]
64struct GitHubTree {
65    tree: Vec<TreeEntry>,
66    truncated: bool,
67}
68
69impl GitHubClient {
70    /// Create a new GitHub client
71    pub fn new() -> Result<Self> {
72        let client = reqwest::Client::builder()
73            .timeout(Duration::from_secs(30))
74            .user_agent("ferrous-forge-template-fetcher/1.0")
75            .build()
76            .map_err(|e| Error::network(format!("Failed to create HTTP client: {e}")))?;
77
78        Ok(Self {
79            client,
80            api_base: "https://api.github.com".to_string(),
81        })
82    }
83
84    /// Parse a repository reference from string
85    ///
86    /// Supports formats:
87    /// - `gh:owner/repo` - default branch
88    /// - `gh:owner/repo@ref` - specific ref
89    /// - `owner/repo` - without gh: prefix
90    /// - `owner/repo@ref` - without gh: prefix
91    pub fn parse_repo_ref(input: &str) -> Result<RepoRef> {
92        let input = input.strip_prefix("gh:").unwrap_or(input);
93
94        let parts: Vec<&str> = input.split('@').collect();
95        let repo_part = parts[0];
96        let git_ref = parts.get(1).map(|s| s.to_string());
97
98        let repo_parts: Vec<&str> = repo_part.split('/').collect();
99        if repo_parts.len() != 2 {
100            return Err(Error::template(format!(
101                "Invalid repository format: '{input}'. Use owner/repo or gh:owner/repo"
102            )));
103        }
104
105        Ok(RepoRef {
106            owner: repo_parts[0].to_string(),
107            repo: repo_parts[1].to_string(),
108            git_ref,
109        })
110    }
111
112    /// Fetch template from GitHub repository
113    pub async fn fetch_template(
114        &self,
115        repo_ref: &RepoRef,
116        repository: &mut TemplateRepository,
117    ) -> Result<CachedTemplate> {
118        // Get repository info
119        let repo_info = self.get_repo_info(repo_ref).await?;
120        let git_ref = repo_ref
121            .git_ref
122            .clone()
123            .unwrap_or_else(|| repo_info.default_branch.clone());
124
125        // Check if already cached and up to date
126        let cache_name = format!("{}-{}", repo_ref.owner, repo_ref.repo);
127        if let Some(cached) = repository.get_cached(&cache_name) {
128            // If we have a specific ref, check if it matches
129            if repo_ref.git_ref.is_some() && cached.version == git_ref {
130                return Ok(cached.clone());
131            }
132            // For default branch, check update time
133            if repo_ref.git_ref.is_none() && !cached.needs_update() {
134                return Ok(cached.clone());
135            }
136        }
137
138        // Fetch template files
139        let template_dir = repository.template_cache_path(&cache_name);
140        self.download_template(repo_ref, &git_ref, &template_dir)
141            .await?;
142
143        // Load and validate manifest
144        let manifest_path = template_dir.join("template.toml");
145        let manifest_content = tokio::fs::read_to_string(&manifest_path)
146            .await
147            .map_err(|e| Error::template(format!("Failed to read template manifest: {e}")))?;
148        let manifest: TemplateManifest = toml::from_str(&manifest_content)
149            .map_err(|e| Error::template(format!("Failed to parse template manifest: {e}")))?;
150
151        // Validate template
152        validate_template_structure(&template_dir, &manifest).await?;
153
154        // Create cached template entry
155        let cached = CachedTemplate {
156            name: cache_name.clone(),
157            source: format!("gh:{}/{}", repo_ref.owner, repo_ref.repo),
158            version: git_ref,
159            fetched_at: chrono::Utc::now(),
160            updated_at: chrono::Utc::now(),
161            cache_path: template_dir,
162            manifest,
163        };
164
165        // Add to cache index
166        repository.add_to_cache(cached.clone())?;
167
168        Ok(cached)
169    }
170
171    /// Get repository information from GitHub API
172    async fn get_repo_info(&self, repo_ref: &RepoRef) -> Result<GitHubRepo> {
173        let url = format!(
174            "{}/repos/{}/{}",
175            self.api_base, repo_ref.owner, repo_ref.repo
176        );
177
178        let response = self
179            .client
180            .get(&url)
181            .send()
182            .await
183            .map_err(|e| Error::network(format!("Failed to fetch repository info: {e}")))?;
184
185        if response.status() == 404 {
186            return Err(Error::template(format!(
187                "Repository not found: {}/{}",
188                repo_ref.owner, repo_ref.repo
189            )));
190        }
191
192        if response.status() == 403 {
193            return Err(Error::template(
194                "GitHub API rate limit exceeded. Please try again later or provide a GitHub token.",
195            ));
196        }
197
198        if !response.status().is_success() {
199            return Err(Error::template(format!(
200                "GitHub API error: {}",
201                response.status()
202            )));
203        }
204
205        let repo: GitHubRepo = response
206            .json()
207            .await
208            .map_err(|e| Error::template(format!("Failed to parse repository info: {e}")))?;
209
210        Ok(repo)
211    }
212
213    /// Download template files from GitHub
214    async fn download_template(
215        &self,
216        repo_ref: &RepoRef,
217        git_ref: &str,
218        target_dir: &Path,
219    ) -> Result<()> {
220        use tokio::io::AsyncWriteExt;
221
222        // Get repository tree
223        let tree = self.get_tree(repo_ref, git_ref).await?;
224
225        // Clean target directory
226        if target_dir.exists() {
227            tokio::fs::remove_dir_all(target_dir)
228                .await
229                .map_err(|e| Error::template(format!("Failed to clean template directory: {e}")))?;
230        }
231
232        tokio::fs::create_dir_all(target_dir)
233            .await
234            .map_err(|e| Error::template(format!("Failed to create template directory: {e}")))?;
235
236        // Download each file
237        for entry in tree.tree {
238            if entry.entry_type != "blob" {
239                continue;
240            }
241
242            let file_path = target_dir.join(&entry.path);
243            if let Some(parent) = file_path.parent() {
244                tokio::fs::create_dir_all(parent)
245                    .await
246                    .map_err(|e| Error::template(format!("Failed to create directory: {e}")))?;
247            }
248
249            let content = self
250                .fetch_file_content(repo_ref, git_ref, &entry.path)
251                .await?;
252
253            let mut file = tokio::fs::File::create(&file_path)
254                .await
255                .map_err(|e| Error::template(format!("Failed to create file: {e}")))?;
256            file.write_all(&content)
257                .await
258                .map_err(|e| Error::template(format!("Failed to write file: {e}")))?;
259        }
260
261        Ok(())
262    }
263
264    /// Get repository tree from GitHub API
265    async fn get_tree(&self, repo_ref: &RepoRef, git_ref: &str) -> Result<GitHubTree> {
266        let url = format!(
267            "{}/repos/{}/{}/git/trees/{}?recursive=1",
268            self.api_base, repo_ref.owner, repo_ref.repo, git_ref
269        );
270
271        let response = self
272            .client
273            .get(&url)
274            .send()
275            .await
276            .map_err(|e| Error::network(format!("Failed to fetch repository tree: {e}")))?;
277
278        if !response.status().is_success() {
279            return Err(Error::template(format!(
280                "Failed to fetch repository tree: {}",
281                response.status()
282            )));
283        }
284
285        let tree: GitHubTree = response
286            .json()
287            .await
288            .map_err(|e| Error::template(format!("Failed to parse repository tree: {e}")))?;
289
290        if tree.truncated {
291            tracing::warn!("Repository tree was truncated, some files may be missing");
292        }
293
294        Ok(tree)
295    }
296
297    /// Fetch file content from GitHub
298    async fn fetch_file_content(
299        &self,
300        repo_ref: &RepoRef,
301        git_ref: &str,
302        path: &str,
303    ) -> Result<Vec<u8>> {
304        let url = format!(
305            "https://raw.githubusercontent.com/{}/{}/{}/{}",
306            repo_ref.owner, repo_ref.repo, git_ref, path
307        );
308
309        let response = self
310            .client
311            .get(&url)
312            .send()
313            .await
314            .map_err(|e| Error::network(format!("Failed to fetch file {path}: {e}")))?;
315
316        if !response.status().is_success() {
317            return Err(Error::template(format!(
318                "Failed to fetch file {path}: {}",
319                response.status()
320            )));
321        }
322
323        let content = response
324            .bytes()
325            .await
326            .map_err(|e| Error::network(format!("Failed to read file content: {e}")))?;
327
328        Ok(content.to_vec())
329    }
330}
331
332/// Validate template structure before installation
333async fn validate_template_structure(
334    template_dir: &Path,
335    manifest: &TemplateManifest,
336) -> Result<()> {
337    use crate::templates::validation::validate_template;
338
339    // Run standard template validation
340    validate_template(template_dir, manifest).await?;
341
342    Ok(())
343}