use anyhow::{bail, Context, Result};
use std::path::{Path, PathBuf};
pub fn cache_dir() -> PathBuf {
dirs::cache_dir()
.unwrap_or_else(std::env::temp_dir)
.join("fledge")
.join("templates")
}
pub fn is_remote_ref(name: &str) -> bool {
name.contains('/')
&& !name.contains(' ')
&& name.split('/').count() >= 2
&& name.split('/').all(|s| !s.is_empty())
}
pub fn parse_remote_ref(name: &str) -> Result<(&str, &str, Option<&str>, Option<&str>)> {
let (name_part, git_ref) = match name.rsplit_once('@') {
Some((before, after)) if !after.is_empty() => (before, Some(after)),
_ => (name, None),
};
let parts: Vec<&str> = name_part.splitn(3, '/').collect();
let owner = parts.first().context("missing owner in remote ref")?;
let repo = parts.get(1).context("missing repo in remote ref")?;
let subpath = parts.get(2).copied();
Ok((*owner, *repo, subpath, git_ref))
}
pub fn clear_cache(owner: &str, repo: &str) -> Result<()> {
let repo_dir = cache_dir().join(owner).join(repo);
if repo_dir.exists() {
std::fs::remove_dir_all(&repo_dir)
.with_context(|| format!("removing cached repo at {}", repo_dir.display()))?;
}
Ok(())
}
pub fn fetch_repo(
owner: &str,
repo: &str,
token: Option<&str>,
git_ref: Option<&str>,
) -> Result<PathBuf> {
let cache = cache_dir();
let ref_suffix = git_ref.unwrap_or("HEAD");
let repo_dir = if git_ref.is_some() {
cache.join(owner).join(format!("{}@{}", repo, ref_suffix))
} else {
cache.join(owner).join(repo)
};
if repo_dir.exists() {
if git_ref.is_none() {
update_repo(&repo_dir)?;
}
} else {
clone_repo(owner, repo, token, &repo_dir, git_ref)?;
}
Ok(repo_dir)
}
fn clone_repo(
owner: &str,
repo: &str,
token: Option<&str>,
target: &Path,
git_ref: Option<&str>,
) -> Result<()> {
std::fs::create_dir_all(target.parent().unwrap_or(target))?;
let url = format!("https://github.com/{}/{}.git", owner, repo);
let mut args = vec!["clone", "--depth", "1"];
if let Some(r) = git_ref {
args.push("--branch");
args.push(r);
}
args.push(&url);
let mut cmd = std::process::Command::new("git");
cmd.args(&args)
.arg(target)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped());
if let Some(t) = token {
use base64::Engine;
let credentials = format!("x-access-token:{}", t);
let encoded = base64::engine::general_purpose::STANDARD.encode(&credentials);
let header_value = format!("Authorization: Basic {}", encoded);
cmd.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "http.extraheader")
.env("GIT_CONFIG_VALUE_0", &header_value);
}
let output = cmd.output().context("running git clone")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let detail = if stderr.trim().is_empty() {
String::new()
} else {
format!("\n git: {}", stderr.trim())
};
if let Some(r) = git_ref {
bail!(
"Failed to clone {}/{}@{}. Check the ref exists.{}",
owner,
repo,
r,
detail
);
}
bail!(
"Failed to clone {}/{}. Check the repo exists and you have access.{}",
owner,
repo,
detail
);
}
Ok(())
}
fn update_repo(repo_dir: &Path) -> Result<()> {
let status = std::process::Command::new("git")
.args(["pull", "--ff-only", "--depth", "1"])
.current_dir(repo_dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.context("running git pull")?;
if !status.success() {
let status = std::process::Command::new("git")
.args(["fetch", "--depth", "1", "origin"])
.current_dir(repo_dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.context("running git fetch")?;
if !status.success() {
bail!("Failed to update cached repo at {}", repo_dir.display());
}
std::process::Command::new("git")
.args(["reset", "--hard", "origin/HEAD"])
.current_dir(repo_dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.context("running git reset")?;
}
Ok(())
}
pub fn resolve_template_dir(
owner: &str,
repo: &str,
subpath: Option<&str>,
token: Option<&str>,
git_ref: Option<&str>,
) -> Result<PathBuf> {
let repo_dir = fetch_repo(owner, repo, token, git_ref)?;
match subpath {
Some(sub) => {
let template_dir = repo_dir.join(sub);
if !template_dir.exists() {
bail!("Subpath '{}' not found in {}/{}", sub, owner, repo);
}
let canonical_template = template_dir
.canonicalize()
.with_context(|| format!("resolving subpath '{}'", sub))?;
let canonical_repo = repo_dir
.canonicalize()
.with_context(|| format!("resolving repo dir '{}'", repo_dir.display()))?;
if !canonical_template.starts_with(&canonical_repo) {
bail!("Subpath '{}' escapes repository directory", sub);
}
Ok(template_dir)
}
None => Ok(repo_dir),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_remote_ref_owner_repo() {
assert!(is_remote_ref("CorvidLabs/fledge-templates"));
}
#[test]
fn is_remote_ref_with_subpath() {
assert!(is_remote_ref("CorvidLabs/fledge-templates/rust-cli"));
}
#[test]
fn is_remote_ref_rejects_simple_name() {
assert!(!is_remote_ref("rust-cli"));
}
#[test]
fn is_remote_ref_rejects_empty_segments() {
assert!(!is_remote_ref("/repo"));
assert!(!is_remote_ref("owner/"));
}
#[test]
fn is_remote_ref_rejects_spaces() {
assert!(!is_remote_ref("owner /repo"));
}
#[test]
fn parse_remote_ref_owner_repo() {
let (owner, repo, sub, git_ref) = parse_remote_ref("CorvidLabs/fledge-templates").unwrap();
assert_eq!(owner, "CorvidLabs");
assert_eq!(repo, "fledge-templates");
assert!(sub.is_none());
assert!(git_ref.is_none());
}
#[test]
fn parse_remote_ref_with_subpath() {
let (owner, repo, sub, git_ref) =
parse_remote_ref("CorvidLabs/fledge-templates/rust-cli").unwrap();
assert_eq!(owner, "CorvidLabs");
assert_eq!(repo, "fledge-templates");
assert_eq!(sub, Some("rust-cli"));
assert!(git_ref.is_none());
}
#[test]
fn parse_remote_ref_deep_subpath() {
let (owner, repo, sub, git_ref) =
parse_remote_ref("CorvidLabs/templates/lang/rust-cli").unwrap();
assert_eq!(owner, "CorvidLabs");
assert_eq!(repo, "templates");
assert_eq!(sub, Some("lang/rust-cli"));
assert!(git_ref.is_none());
}
#[test]
fn parse_remote_ref_with_version_tag() {
let (owner, repo, sub, git_ref) =
parse_remote_ref("CorvidLabs/my-template@v1.2.0").unwrap();
assert_eq!(owner, "CorvidLabs");
assert_eq!(repo, "my-template");
assert!(sub.is_none());
assert_eq!(git_ref, Some("v1.2.0"));
}
#[test]
fn parse_remote_ref_with_branch() {
let (owner, repo, sub, git_ref) = parse_remote_ref("user/repo@main").unwrap();
assert_eq!(owner, "user");
assert_eq!(repo, "repo");
assert!(sub.is_none());
assert_eq!(git_ref, Some("main"));
}
#[test]
fn parse_remote_ref_subpath_with_ref() {
let (owner, repo, sub, git_ref) =
parse_remote_ref("CorvidLabs/templates/rust-cli@v2.0").unwrap();
assert_eq!(owner, "CorvidLabs");
assert_eq!(repo, "templates");
assert_eq!(sub, Some("rust-cli"));
assert_eq!(git_ref, Some("v2.0"));
}
#[test]
fn parse_remote_ref_missing_repo() {
assert!(parse_remote_ref("owner-only").is_err());
}
#[test]
fn cache_dir_ends_with_expected_path() {
let dir = cache_dir();
assert!(dir.ends_with("fledge/templates"));
}
#[test]
fn clear_cache_removes_dir() {
let tmp = tempfile::TempDir::new().unwrap();
let fake_cache = tmp.path().join("owner").join("repo");
std::fs::create_dir_all(&fake_cache).unwrap();
std::fs::write(fake_cache.join("file.txt"), "data").unwrap();
assert!(fake_cache.exists());
std::fs::remove_dir_all(&fake_cache).unwrap();
assert!(!fake_cache.exists());
}
#[test]
fn clear_cache_nonexistent_is_ok() {
let result = clear_cache("nonexistent-owner", "nonexistent-repo");
assert!(result.is_ok());
}
}