ferrous_forge/templates/repository/
github.rs1use 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
13pub struct GitHubClient {
15 client: reqwest::Client,
16 api_base: String,
17}
18
19#[derive(Debug, Clone)]
21pub struct RepoRef {
22 pub owner: String,
24 pub repo: String,
26 pub git_ref: Option<String>,
28}
29
30#[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#[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#[derive(Debug, Deserialize)]
64struct GitHubTree {
65 tree: Vec<TreeEntry>,
66 truncated: bool,
67}
68
69impl GitHubClient {
70 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 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 pub async fn fetch_template(
126 &self,
127 repo_ref: &RepoRef,
128 repository: &mut TemplateRepository,
129 ) -> Result<CachedTemplate> {
130 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 let cache_name = format!("{}-{}", repo_ref.owner, repo_ref.repo);
139 if let Some(cached) = repository.get_cached(&cache_name) {
140 if repo_ref.git_ref.is_some() && cached.version == git_ref {
142 return Ok(cached.clone());
143 }
144 if repo_ref.git_ref.is_none() && !cached.needs_update() {
146 return Ok(cached.clone());
147 }
148 }
149
150 let template_dir = repository.template_cache_path(&cache_name);
152 self.download_template(repo_ref, &git_ref, &template_dir)
153 .await?;
154
155 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_structure(&template_dir, &manifest).await?;
165
166 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 repository.add_to_cache(cached.clone())?;
179
180 Ok(cached)
181 }
182
183 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 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 let tree = self.get_tree(repo_ref, git_ref).await?;
236
237 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 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 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 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
344async fn validate_template_structure(
346 template_dir: &Path,
347 manifest: &TemplateManifest,
348) -> Result<()> {
349 use crate::templates::validation::validate_template;
350
351 validate_template(template_dir, manifest).await?;
353
354 Ok(())
355}