use crate::error::{Error, Result};
use crate::templates::manifest::TemplateManifest;
use crate::templates::repository::{CachedTemplate, TemplateRepository};
use serde::Deserialize;
use std::path::Path;
use std::time::Duration;
pub struct GitHubClient {
client: reqwest::Client,
api_base: String,
}
#[derive(Debug, Clone)]
pub struct RepoRef {
pub owner: String,
pub repo: String,
pub git_ref: Option<String>,
}
#[derive(Debug, Deserialize)]
struct GitHubRepo {
#[allow(dead_code)]
id: u64,
#[allow(dead_code)]
name: String,
#[allow(dead_code)]
full_name: String,
#[allow(dead_code)]
description: Option<String>,
#[serde(rename = "stargazers_count")]
#[allow(dead_code)]
stars: u32,
#[serde(rename = "updated_at")]
#[allow(dead_code)]
updated_at: String,
default_branch: String,
}
#[derive(Debug, Deserialize)]
struct TreeEntry {
path: String,
#[serde(rename = "type")]
entry_type: String,
#[allow(dead_code)]
sha: String,
#[allow(dead_code)]
size: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct GitHubTree {
tree: Vec<TreeEntry>,
truncated: bool,
}
impl GitHubClient {
pub fn new() -> Result<Self> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.user_agent("ferrous-forge-template-fetcher/1.0")
.build()
.map_err(|e| Error::network(format!("Failed to create HTTP client: {e}")))?;
Ok(Self {
client,
api_base: "https://api.github.com".to_string(),
})
}
pub fn parse_repo_ref(input: &str) -> Result<RepoRef> {
let input = input.strip_prefix("gh:").unwrap_or(input);
let parts: Vec<&str> = input.split('@').collect();
let repo_part = parts[0];
let git_ref = parts.get(1).map(|s| s.to_string());
let repo_parts: Vec<&str> = repo_part.split('/').collect();
if repo_parts.len() != 2 {
return Err(Error::template(format!(
"Invalid repository format: '{input}'. Use owner/repo or gh:owner/repo"
)));
}
Ok(RepoRef {
owner: repo_parts[0].to_string(),
repo: repo_parts[1].to_string(),
git_ref,
})
}
pub async fn fetch_template(
&self,
repo_ref: &RepoRef,
repository: &mut TemplateRepository,
) -> Result<CachedTemplate> {
let repo_info = self.get_repo_info(repo_ref).await?;
let git_ref = repo_ref
.git_ref
.clone()
.unwrap_or_else(|| repo_info.default_branch.clone());
let cache_name = format!("{}-{}", repo_ref.owner, repo_ref.repo);
if let Some(cached) = repository.get_cached(&cache_name) {
if repo_ref.git_ref.is_some() && cached.version == git_ref {
return Ok(cached.clone());
}
if repo_ref.git_ref.is_none() && !cached.needs_update() {
return Ok(cached.clone());
}
}
let template_dir = repository.template_cache_path(&cache_name);
self.download_template(repo_ref, &git_ref, &template_dir)
.await?;
let manifest_path = template_dir.join("template.toml");
let manifest_content = tokio::fs::read_to_string(&manifest_path)
.await
.map_err(|e| Error::template(format!("Failed to read template manifest: {e}")))?;
let manifest: TemplateManifest = toml::from_str(&manifest_content)
.map_err(|e| Error::template(format!("Failed to parse template manifest: {e}")))?;
validate_template_structure(&template_dir, &manifest).await?;
let cached = CachedTemplate {
name: cache_name.clone(),
source: format!("gh:{}/{}", repo_ref.owner, repo_ref.repo),
version: git_ref,
fetched_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
cache_path: template_dir,
manifest,
};
repository.add_to_cache(cached.clone())?;
Ok(cached)
}
async fn get_repo_info(&self, repo_ref: &RepoRef) -> Result<GitHubRepo> {
let url = format!(
"{}/repos/{}/{}",
self.api_base, repo_ref.owner, repo_ref.repo
);
let response = self
.client
.get(&url)
.send()
.await
.map_err(|e| Error::network(format!("Failed to fetch repository info: {e}")))?;
if response.status() == 404 {
return Err(Error::template(format!(
"Repository not found: {}/{}",
repo_ref.owner, repo_ref.repo
)));
}
if response.status() == 403 {
return Err(Error::template(
"GitHub API rate limit exceeded. Please try again later or provide a GitHub token.",
));
}
if !response.status().is_success() {
return Err(Error::template(format!(
"GitHub API error: {}",
response.status()
)));
}
let repo: GitHubRepo = response
.json()
.await
.map_err(|e| Error::template(format!("Failed to parse repository info: {e}")))?;
Ok(repo)
}
async fn download_template(
&self,
repo_ref: &RepoRef,
git_ref: &str,
target_dir: &Path,
) -> Result<()> {
use tokio::io::AsyncWriteExt;
let tree = self.get_tree(repo_ref, git_ref).await?;
if target_dir.exists() {
tokio::fs::remove_dir_all(target_dir)
.await
.map_err(|e| Error::template(format!("Failed to clean template directory: {e}")))?;
}
tokio::fs::create_dir_all(target_dir)
.await
.map_err(|e| Error::template(format!("Failed to create template directory: {e}")))?;
for entry in tree.tree {
if entry.entry_type != "blob" {
continue;
}
let file_path = target_dir.join(&entry.path);
if let Some(parent) = file_path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| Error::template(format!("Failed to create directory: {e}")))?;
}
let content = self
.fetch_file_content(repo_ref, git_ref, &entry.path)
.await?;
let mut file = tokio::fs::File::create(&file_path)
.await
.map_err(|e| Error::template(format!("Failed to create file: {e}")))?;
file.write_all(&content)
.await
.map_err(|e| Error::template(format!("Failed to write file: {e}")))?;
}
Ok(())
}
async fn get_tree(&self, repo_ref: &RepoRef, git_ref: &str) -> Result<GitHubTree> {
let url = format!(
"{}/repos/{}/{}/git/trees/{}?recursive=1",
self.api_base, repo_ref.owner, repo_ref.repo, git_ref
);
let response = self
.client
.get(&url)
.send()
.await
.map_err(|e| Error::network(format!("Failed to fetch repository tree: {e}")))?;
if !response.status().is_success() {
return Err(Error::template(format!(
"Failed to fetch repository tree: {}",
response.status()
)));
}
let tree: GitHubTree = response
.json()
.await
.map_err(|e| Error::template(format!("Failed to parse repository tree: {e}")))?;
if tree.truncated {
tracing::warn!("Repository tree was truncated, some files may be missing");
}
Ok(tree)
}
async fn fetch_file_content(
&self,
repo_ref: &RepoRef,
git_ref: &str,
path: &str,
) -> Result<Vec<u8>> {
let url = format!(
"https://raw.githubusercontent.com/{}/{}/{}/{}",
repo_ref.owner, repo_ref.repo, git_ref, path
);
let response = self
.client
.get(&url)
.send()
.await
.map_err(|e| Error::network(format!("Failed to fetch file {path}: {e}")))?;
if !response.status().is_success() {
return Err(Error::template(format!(
"Failed to fetch file {path}: {}",
response.status()
)));
}
let content = response
.bytes()
.await
.map_err(|e| Error::network(format!("Failed to read file content: {e}")))?;
Ok(content.to_vec())
}
}
async fn validate_template_structure(
template_dir: &Path,
manifest: &TemplateManifest,
) -> Result<()> {
use crate::templates::validation::validate_template;
validate_template(template_dir, manifest).await?;
Ok(())
}