use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Database {
#[serde(default)]
pub taps: HashMap<String, TapInfo>,
#[serde(default)]
pub installed: HashMap<String, InstalledSkill>,
#[serde(default)]
pub external: HashMap<String, ExternalSkill>,
#[serde(default)]
pub linked_agents: HashSet<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TapInfo {
pub url: String,
pub skills_path: String,
pub updated_at: Option<DateTime<Utc>>,
#[serde(default)]
pub is_default: bool,
#[serde(default)]
pub is_bundled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cached_registry: Option<TapRegistry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstalledSkill {
pub tap: String,
pub skill: String,
pub commit: Option<String>,
pub installed_at: DateTime<Utc>,
#[serde(default)]
pub local: bool,
#[serde(default)]
pub source_url: Option<String>,
#[serde(default)]
pub source_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExternalSkill {
pub name: String,
pub source_agent: String,
pub source_path: PathBuf,
pub discovered_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TapRegistry {
pub name: String,
pub description: Option<String>,
#[serde(default)]
pub skills: HashMap<String, SkillEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillEntry {
pub path: String,
pub description: Option<String>,
pub homepage: Option<String>,
}
#[derive(Debug, Clone)]
pub struct GitHubUrl {
pub owner: String,
pub repo: String,
pub branch: Option<String>,
pub path: Option<String>,
}
impl GitHubUrl {
fn github_api_base() -> String {
std::env::var("SKILLSHUB_GITHUB_API_BASE").unwrap_or_else(|_| "https://api.github.com".to_string())
}
fn github_raw_base() -> String {
std::env::var("SKILLSHUB_GITHUB_RAW_BASE").unwrap_or_else(|_| "https://raw.githubusercontent.com".to_string())
}
pub fn is_commit_sha(&self) -> bool {
self.branch
.as_ref()
.map(|b| b.len() >= 7 && b.chars().all(|c| c.is_ascii_hexdigit()))
.unwrap_or(false)
}
pub fn skill_name(&self) -> Option<String> {
self.path
.as_ref()
.and_then(|p| p.split('/').next_back())
.map(|s| s.to_string())
}
pub fn tap_name(&self) -> String {
format!("{}/{}", self.owner, self.repo)
}
pub fn base_url(&self) -> String {
format!("https://github.com/{}/{}", self.owner, self.repo)
}
pub fn api_url(&self) -> String {
format!("{}/repos/{}/{}", Self::github_api_base(), self.owner, self.repo)
}
pub fn tarball_url(&self, git_ref: &str) -> String {
format!(
"{}/repos/{}/{}/tarball/{}",
Self::github_api_base(),
self.owner,
self.repo,
git_ref
)
}
pub fn raw_url(&self, path: &str, branch: &str) -> String {
format!(
"{}/{}/{}/{}/{}",
Self::github_raw_base(),
self.owner,
self.repo,
branch,
path
)
}
}
#[derive(Debug, Clone)]
pub struct SkillId {
pub tap: String,
pub skill: String,
}
impl SkillId {
pub fn parse(s: &str) -> Option<Self> {
let base = s.split('@').next().unwrap_or(s);
let parts: Vec<&str> = base.split('/').collect();
match parts.len() {
3 if !parts[0].is_empty() && !parts[1].is_empty() && !parts[2].is_empty() => Some(Self {
tap: format!("{}/{}", parts[0], parts[1]),
skill: parts[2].to_string(),
}),
2 if !parts[0].is_empty() && !parts[1].is_empty() => Some(Self {
tap: parts[0].to_string(),
skill: parts[1].to_string(),
}),
_ => None,
}
}
pub fn parse_commit(s: &str) -> Option<String> {
s.split('@').nth(1).map(|s| s.to_string())
}
pub fn full_name(&self) -> String {
format!("{}/{}", self.tap, self.skill)
}
}
impl std::fmt::Display for SkillId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}/{}", self.tap, self.skill)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_skill_id_parse_legacy_format() {
let id = SkillId::parse("skillshub/code-reviewer").unwrap();
assert_eq!(id.tap, "skillshub");
assert_eq!(id.skill, "code-reviewer");
}
#[test]
fn test_skill_id_parse_new_format() {
let id = SkillId::parse("EYH0602/skillshub/code-reviewer").unwrap();
assert_eq!(id.tap, "EYH0602/skillshub");
assert_eq!(id.skill, "code-reviewer");
}
#[test]
fn test_skill_id_parse_with_commit() {
let id = SkillId::parse("tap/skill@abc123").unwrap();
assert_eq!(id.tap, "tap");
assert_eq!(id.skill, "skill");
let id2 = SkillId::parse("owner/repo/skill@abc123").unwrap();
assert_eq!(id2.tap, "owner/repo");
assert_eq!(id2.skill, "skill");
let commit = SkillId::parse_commit("owner/repo/skill@abc123");
assert_eq!(commit, Some("abc123".to_string()));
}
#[test]
fn test_skill_id_parse_invalid() {
assert!(SkillId::parse("no-slash").is_none());
assert!(SkillId::parse("/skill").is_none());
assert!(SkillId::parse("tap/").is_none());
assert!(SkillId::parse("").is_none());
assert!(SkillId::parse("a/b/c/d").is_none()); }
#[test]
fn test_skill_id_full_name() {
let id = SkillId {
tap: "owner/repo".to_string(),
skill: "my-skill".to_string(),
};
assert_eq!(id.full_name(), "owner/repo/my-skill");
}
#[test]
fn test_github_url_methods() {
let url = GitHubUrl {
owner: "user".to_string(),
repo: "repo".to_string(),
branch: Some("main".to_string()),
path: Some("skills".to_string()),
};
assert_eq!(url.tap_name(), "user/repo");
assert_eq!(url.base_url(), "https://github.com/user/repo");
assert_eq!(url.api_url(), "https://api.github.com/repos/user/repo");
assert_eq!(
url.tarball_url("main"),
"https://api.github.com/repos/user/repo/tarball/main"
);
assert_eq!(
url.raw_url("registry.json", "main"),
"https://raw.githubusercontent.com/user/repo/main/registry.json"
);
}
#[test]
fn test_github_url_with_no_branch() {
let url = GitHubUrl {
owner: "user".to_string(),
repo: "repo".to_string(),
branch: None,
path: None,
};
assert!(!url.is_commit_sha());
assert_eq!(url.tap_name(), "user/repo");
}
#[test]
fn test_database_default() {
let db = Database::default();
assert!(db.taps.is_empty());
assert!(db.installed.is_empty());
assert!(db.external.is_empty());
}
#[test]
fn test_tap_info_serialize() {
let tap = TapInfo {
url: "https://github.com/user/repo".to_string(),
skills_path: "skills".to_string(),
updated_at: None,
is_default: false,
is_bundled: false,
cached_registry: None,
};
let json = serde_json::to_string(&tap).unwrap();
assert!(json.contains("user/repo"));
assert!(!json.contains("cached_registry"));
}
#[test]
fn test_tap_info_with_cached_registry() {
let mut skills = HashMap::new();
skills.insert(
"my-skill".to_string(),
SkillEntry {
path: "skills/my-skill".to_string(),
description: Some("A test skill".to_string()),
homepage: None,
},
);
let registry = TapRegistry {
name: "test-tap".to_string(),
description: Some("Test tap".to_string()),
skills,
};
let tap = TapInfo {
url: "https://github.com/user/repo".to_string(),
skills_path: "skills".to_string(),
updated_at: None,
is_default: false,
is_bundled: false,
cached_registry: Some(registry),
};
let json = serde_json::to_string(&tap).unwrap();
assert!(json.contains("cached_registry"));
assert!(json.contains("my-skill"));
assert!(json.contains("A test skill"));
}
#[test]
fn test_tap_info_deserialize_without_cache() {
let json = r#"{
"url": "https://github.com/user/repo",
"skills_path": "skills",
"updated_at": null,
"is_default": false,
"is_bundled": false
}"#;
let tap: TapInfo = serde_json::from_str(json).unwrap();
assert!(tap.cached_registry.is_none());
}
#[test]
fn test_tap_info_roundtrip_with_cache() {
let mut skills = HashMap::new();
skills.insert(
"skill1".to_string(),
SkillEntry {
path: "skills/skill1".to_string(),
description: Some("First skill".to_string()),
homepage: Some("https://example.com".to_string()),
},
);
skills.insert(
"skill2".to_string(),
SkillEntry {
path: "other/skill2".to_string(),
description: None,
homepage: None,
},
);
let registry = TapRegistry {
name: "my-tap".to_string(),
description: None,
skills,
};
let tap = TapInfo {
url: "https://github.com/owner/repo".to_string(),
skills_path: "skills".to_string(),
updated_at: Some(chrono::Utc::now()),
is_default: false,
is_bundled: false,
cached_registry: Some(registry),
};
let json = serde_json::to_string(&tap).unwrap();
let restored: TapInfo = serde_json::from_str(&json).unwrap();
assert_eq!(restored.url, tap.url);
assert!(restored.cached_registry.is_some());
let cached = restored.cached_registry.unwrap();
assert_eq!(cached.name, "my-tap");
assert_eq!(cached.skills.len(), 2);
assert!(cached.skills.contains_key("skill1"));
assert!(cached.skills.contains_key("skill2"));
}
}