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