use std::path::{Path, PathBuf};
use std::process::Command;
use tracing::{debug, info, warn};
use punch_types::PunchResult;
use crate::registry::{IndexEntry, IndexMeta, index_path_for_name};
pub const DEFAULT_INDEX_URL: &str = "https://github.com/humancto/punch-marketplace.git";
const INDEX_DIR_NAME: &str = "index";
const CACHE_DIR_NAME: &str = "cache";
pub struct IndexClient {
index_url: String,
index_dir: PathBuf,
cache_dir: PathBuf,
}
impl IndexClient {
pub fn new(index_url: &str, base_dir: &Path) -> Self {
Self {
index_url: index_url.to_string(),
index_dir: base_dir.join(INDEX_DIR_NAME),
cache_dir: base_dir.join(CACHE_DIR_NAME),
}
}
pub fn with_defaults(base_dir: &Path) -> Self {
Self::new(DEFAULT_INDEX_URL, base_dir)
}
pub fn sync(&self) -> PunchResult<()> {
std::fs::create_dir_all(&self.index_dir).map_err(|e| {
punch_types::PunchError::Config(format!("failed to create index directory: {}", e))
})?;
if self.index_dir.join(".git").exists() {
info!(path = %self.index_dir.display(), "pulling index updates");
let output = Command::new("git")
.args(["pull", "--ff-only", "--quiet"])
.current_dir(&self.index_dir)
.output()
.map_err(|e| {
punch_types::PunchError::Config(format!("failed to run git pull: {}", e))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!(stderr = %stderr, "git pull failed, continuing with existing index");
}
} else {
info!(url = %self.index_url, path = %self.index_dir.display(), "cloning index");
let output = Command::new("git")
.args([
"clone",
"--depth",
"1",
"--quiet",
&self.index_url,
self.index_dir.to_str().unwrap_or("."),
])
.output()
.map_err(|e| {
punch_types::PunchError::Config(format!("failed to run git clone: {}", e))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(punch_types::PunchError::Config(format!(
"failed to clone index: {}",
stderr
)));
}
}
Ok(())
}
pub fn search(
&self,
query: &str,
category: Option<&str>,
tags: Option<&[String]>,
) -> PunchResult<Vec<IndexMeta>> {
let entries = self.read_all_entries()?;
let query_lower = query.to_lowercase();
let results: Vec<IndexMeta> = entries
.into_iter()
.filter(|meta| {
if let Some(cat) = category
&& !meta.name.contains(cat)
{
return false;
}
if let Some(search_tags) = tags {
let meta_str = serde_json::to_string(meta)
.unwrap_or_default()
.to_lowercase();
if !search_tags
.iter()
.any(|t| meta_str.contains(&t.to_lowercase()))
{
return false;
}
}
if query.is_empty() {
return true;
}
meta.name.to_lowercase().contains(&query_lower)
})
.collect();
Ok(results)
}
pub fn get_entry(&self, name: &str, version: &str) -> PunchResult<IndexEntry> {
let meta = self.read_entry(name)?;
meta.versions
.into_iter()
.find(|v| v.version == version)
.ok_or_else(|| {
punch_types::PunchError::Config(format!(
"version {} not found for skill '{}'",
version, name
))
})
}
pub fn resolve_version(&self, name: &str, version_req: &str) -> PunchResult<String> {
let meta = self.read_entry(name)?;
if meta.versions.is_empty() {
return Err(punch_types::PunchError::Config(format!(
"no versions found for skill '{}'",
name
)));
}
match version_req {
"latest" | "*" | "" => Ok(meta.versions[0].version.clone()),
exact => {
if meta.versions.iter().any(|v| v.version == exact) {
Ok(exact.to_string())
} else {
Err(punch_types::PunchError::Config(format!(
"version {} not found for skill '{}'. Available: {}",
exact,
name,
meta.versions
.iter()
.map(|v| v.version.as_str())
.collect::<Vec<_>>()
.join(", ")
)))
}
}
}
}
pub async fn fetch_skill(&self, entry: &IndexEntry) -> PunchResult<Vec<u8>> {
let cache_key = format!("{}-{}.tar.gz", entry.name, entry.version);
let cache_path = self.cache_dir.join(&cache_key);
if cache_path.exists() {
debug!(path = %cache_path.display(), "loading skill from cache");
return std::fs::read(&cache_path).map_err(|e| {
punch_types::PunchError::Config(format!("failed to read cached skill: {}", e))
});
}
info!(url = %entry.source_url, "fetching skill tarball");
let response = reqwest::get(&entry.source_url).await.map_err(|e| {
punch_types::PunchError::Config(format!("failed to fetch skill: {}", e))
})?;
if !response.status().is_success() {
return Err(punch_types::PunchError::Config(format!(
"failed to fetch skill: HTTP {}",
response.status()
)));
}
let data = response.bytes().await.map_err(|e| {
punch_types::PunchError::Config(format!("failed to read response: {}", e))
})?;
let data = data.to_vec();
std::fs::create_dir_all(&self.cache_dir).ok();
if let Err(e) = std::fs::write(&cache_path, &data) {
warn!(error = %e, "failed to cache skill tarball");
}
Ok(data)
}
pub fn index_dir(&self) -> &Path {
&self.index_dir
}
pub fn cache_dir(&self) -> &Path {
&self.cache_dir
}
fn read_all_entries(&self) -> PunchResult<Vec<IndexMeta>> {
let mut results = Vec::new();
if !self.index_dir.exists() {
return Ok(results);
}
walk_index_dir(&self.index_dir, &mut results)?;
Ok(results)
}
fn read_entry(&self, name: &str) -> PunchResult<IndexMeta> {
let rel_path = index_path_for_name(name);
let file_path = self.index_dir.join(&rel_path).with_extension("json");
if !file_path.exists() {
return Err(punch_types::PunchError::Config(format!(
"skill '{}' not found in index (looked at {})",
name,
file_path.display()
)));
}
let content = std::fs::read_to_string(&file_path)?;
let meta: IndexMeta = serde_json::from_str(&content).map_err(|e| {
punch_types::PunchError::Config(format!("invalid index entry for '{}': {}", name, e))
})?;
Ok(meta)
}
}
fn walk_index_dir(dir: &Path, results: &mut Vec<IndexMeta>) -> PunchResult<()> {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return Ok(()),
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if path.file_name().is_some_and(|n| n.to_str() == Some(".git")) {
continue;
}
walk_index_dir(&path, results)?;
} else if path.extension().is_some_and(|e| e == "json") {
match std::fs::read_to_string(&path) {
Ok(content) => match serde_json::from_str::<IndexMeta>(&content) {
Ok(meta) => results.push(meta),
Err(e) => {
debug!(path = %path.display(), error = %e, "skipping invalid index file");
}
},
Err(e) => {
debug!(path = %path.display(), error = %e, "failed to read index file");
}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::{IndexEntry, IndexMeta, ScanVerdict};
#[test]
fn test_client_creation() {
let dir = tempfile::tempdir().unwrap();
let client = IndexClient::new("https://example.com/index.git", dir.path());
assert_eq!(client.index_url, "https://example.com/index.git");
assert!(client.index_dir().ends_with("index"));
assert!(client.cache_dir().ends_with("cache"));
}
#[test]
fn test_client_with_defaults() {
let dir = tempfile::tempdir().unwrap();
let client = IndexClient::with_defaults(dir.path());
assert_eq!(client.index_url, DEFAULT_INDEX_URL);
}
#[test]
fn test_read_all_entries_empty() {
let dir = tempfile::tempdir().unwrap();
let client = IndexClient::new("https://example.com/index.git", dir.path());
let entries = client.read_all_entries().unwrap();
assert!(entries.is_empty());
}
#[test]
fn test_read_all_entries_with_data() {
let dir = tempfile::tempdir().unwrap();
let index_dir = dir.path().join("index");
let skill_dir = index_dir.join("co");
std::fs::create_dir_all(&skill_dir).unwrap();
let meta = IndexMeta {
name: "code-reviewer".to_string(),
versions: vec![IndexEntry {
name: "code-reviewer".to_string(),
version: "1.0.0".to_string(),
checksum: "abc".to_string(),
signature: "sig".to_string(),
public_key: "pub".to_string(),
source_url: "https://example.com/cr.tar.gz".to_string(),
scan_result: ScanVerdict::Clean,
}],
install_count: 42,
rating: 4.5,
report_count: 0,
yanked: false,
};
let json = serde_json::to_string_pretty(&meta).unwrap();
std::fs::write(skill_dir.join("code-reviewer.json"), json).unwrap();
let client = IndexClient::new("https://example.com/index.git", dir.path());
let entries = client.read_all_entries().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "code-reviewer");
}
#[test]
fn test_search_by_name() {
let dir = tempfile::tempdir().unwrap();
let index_dir = dir.path().join("index");
let skill_dir = index_dir.join("co");
std::fs::create_dir_all(&skill_dir).unwrap();
let meta = IndexMeta {
name: "code-reviewer".to_string(),
versions: vec![],
install_count: 0,
rating: 0.0,
report_count: 0,
yanked: false,
};
std::fs::write(
skill_dir.join("code-reviewer.json"),
serde_json::to_string(&meta).unwrap(),
)
.unwrap();
let client = IndexClient::new("https://example.com", dir.path());
let results = client.search("code", None, None).unwrap();
assert_eq!(results.len(), 1);
let no_results = client.search("zzzzz", None, None).unwrap();
assert!(no_results.is_empty());
}
#[test]
fn test_read_entry() {
let dir = tempfile::tempdir().unwrap();
let index_dir = dir.path().join("index");
let skill_dir = index_dir.join("co");
std::fs::create_dir_all(&skill_dir).unwrap();
let meta = IndexMeta {
name: "code-reviewer".to_string(),
versions: vec![IndexEntry {
name: "code-reviewer".to_string(),
version: "1.0.0".to_string(),
checksum: "abc".to_string(),
signature: "sig".to_string(),
public_key: "pub".to_string(),
source_url: "https://example.com/cr.tar.gz".to_string(),
scan_result: ScanVerdict::Clean,
}],
install_count: 0,
rating: 0.0,
report_count: 0,
yanked: false,
};
std::fs::write(
skill_dir.join("code-reviewer.json"),
serde_json::to_string(&meta).unwrap(),
)
.unwrap();
let client = IndexClient::new("https://example.com", dir.path());
let entry = client.get_entry("code-reviewer", "1.0.0").unwrap();
assert_eq!(entry.name, "code-reviewer");
assert_eq!(entry.version, "1.0.0");
}
#[test]
fn test_read_entry_not_found() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("index")).unwrap();
let client = IndexClient::new("https://example.com", dir.path());
let result = client.read_entry("nonexistent");
assert!(result.is_err());
}
#[test]
fn test_resolve_version_latest() {
let dir = tempfile::tempdir().unwrap();
let index_dir = dir.path().join("index");
let skill_dir = index_dir.join("co");
std::fs::create_dir_all(&skill_dir).unwrap();
let meta = IndexMeta {
name: "code-reviewer".to_string(),
versions: vec![
IndexEntry {
name: "code-reviewer".to_string(),
version: "2.0.0".to_string(),
checksum: "abc".to_string(),
signature: "sig".to_string(),
public_key: "pub".to_string(),
source_url: "url".to_string(),
scan_result: ScanVerdict::Clean,
},
IndexEntry {
name: "code-reviewer".to_string(),
version: "1.0.0".to_string(),
checksum: "def".to_string(),
signature: "sig".to_string(),
public_key: "pub".to_string(),
source_url: "url".to_string(),
scan_result: ScanVerdict::Clean,
},
],
install_count: 0,
rating: 0.0,
report_count: 0,
yanked: false,
};
std::fs::write(
skill_dir.join("code-reviewer.json"),
serde_json::to_string(&meta).unwrap(),
)
.unwrap();
let client = IndexClient::new("https://example.com", dir.path());
let version = client.resolve_version("code-reviewer", "latest").unwrap();
assert_eq!(version, "2.0.0");
let exact = client.resolve_version("code-reviewer", "1.0.0").unwrap();
assert_eq!(exact, "1.0.0");
}
#[test]
fn test_resolve_version_not_found() {
let dir = tempfile::tempdir().unwrap();
let index_dir = dir.path().join("index");
let skill_dir = index_dir.join("co");
std::fs::create_dir_all(&skill_dir).unwrap();
let meta = IndexMeta {
name: "code-reviewer".to_string(),
versions: vec![IndexEntry {
name: "code-reviewer".to_string(),
version: "1.0.0".to_string(),
checksum: "abc".to_string(),
signature: "sig".to_string(),
public_key: "pub".to_string(),
source_url: "url".to_string(),
scan_result: ScanVerdict::Clean,
}],
install_count: 0,
rating: 0.0,
report_count: 0,
yanked: false,
};
std::fs::write(
skill_dir.join("code-reviewer.json"),
serde_json::to_string(&meta).unwrap(),
)
.unwrap();
let client = IndexClient::new("https://example.com", dir.path());
let result = client.resolve_version("code-reviewer", "99.0.0");
assert!(result.is_err());
}
}