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> {
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 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 pub async fn fetch_template(
114 &self,
115 repo_ref: &RepoRef,
116 repository: &mut TemplateRepository,
117 ) -> Result<CachedTemplate> {
118 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 let cache_name = format!("{}-{}", repo_ref.owner, repo_ref.repo);
127 if let Some(cached) = repository.get_cached(&cache_name) {
128 if repo_ref.git_ref.is_some() && cached.version == git_ref {
130 return Ok(cached.clone());
131 }
132 if repo_ref.git_ref.is_none() && !cached.needs_update() {
134 return Ok(cached.clone());
135 }
136 }
137
138 let template_dir = repository.template_cache_path(&cache_name);
140 self.download_template(repo_ref, &git_ref, &template_dir)
141 .await?;
142
143 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_structure(&template_dir, &manifest).await?;
153
154 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 repository.add_to_cache(cached.clone())?;
167
168 Ok(cached)
169 }
170
171 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 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 let tree = self.get_tree(repo_ref, git_ref).await?;
224
225 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 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 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 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
332async fn validate_template_structure(
334 template_dir: &Path,
335 manifest: &TemplateManifest,
336) -> Result<()> {
337 use crate::templates::validation::validate_template;
338
339 validate_template(template_dir, manifest).await?;
341
342 Ok(())
343}