use super::error::{ProfileError, ProfileResult};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ProviderManifest {
pub name: String,
pub owner: ProviderOwner,
pub profiles: Vec<ProviderProfile>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<ProviderMetadata>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ProviderOwner {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ProviderMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
#[serde(rename = "settingsFile", skip_serializing_if = "Option::is_none")]
pub settings_file: Option<String>,
#[serde(rename = "profileRoot", skip_serializing_if = "Option::is_none")]
pub profile_root: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ProviderProfile {
pub name: String,
pub source: ProviderProfileSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub requires: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub keywords: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum ProviderProfileSource {
Path(String),
Descriptor(ProviderProfileSourceDescriptor),
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ProviderProfileSourceDescriptor {
pub source: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub repo: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subdir: Option<String>,
#[serde(rename = "ref", skip_serializing_if = "Option::is_none")]
pub git_ref: Option<String>,
}
#[derive(Debug, Clone)]
pub enum ResolvedProfileSource {
ProviderPath { relative: String },
Git {
url: String,
git_ref: Option<String>,
subdir: Option<String>,
},
}
impl ProviderManifest {
pub fn from_file(path: &Path) -> ProfileResult<Self> {
let content = std::fs::read_to_string(path)?;
Self::from_json(&content)
}
pub fn from_json(json: &str) -> ProfileResult<Self> {
let manifest: Self = serde_json::from_str(json)?;
manifest.validate()?;
Ok(manifest)
}
pub fn validate(&self) -> ProfileResult<()> {
if self.name.is_empty() {
return Err(ProfileError::InvalidManifest {
reason: "Provider name cannot be empty".to_string(),
});
}
if self.owner.name.is_empty() {
return Err(ProfileError::InvalidManifest {
reason: "Provider owner name cannot be empty".to_string(),
});
}
if self.profiles.is_empty() {
return Err(ProfileError::InvalidManifest {
reason: "Provider must contain at least one profile".to_string(),
});
}
for profile in &self.profiles {
if profile.name.is_empty() {
return Err(ProfileError::InvalidManifest {
reason: "Profile name cannot be empty".to_string(),
});
}
}
Ok(())
}
pub fn get_profile(&self, name: &str) -> Option<&ProviderProfile> {
self.profiles.iter().find(|p| p.name == name)
}
}
impl ProviderProfileSource {
pub fn resolve(&self, provider_root: Option<&str>) -> ResolvedProfileSource {
match self {
ProviderProfileSource::Path(path) => {
let relative = provider_root
.map(|root| format!("{root}/{path}"))
.unwrap_or_else(|| path.clone());
ResolvedProfileSource::ProviderPath { relative }
}
ProviderProfileSource::Descriptor(desc) => {
let url = if desc.source == "github" {
desc.repo
.as_ref()
.map(|repo| format!("https://github.com/{repo}.git"))
.unwrap_or_default()
} else {
desc.url.clone().unwrap_or_default()
};
ResolvedProfileSource::Git {
url,
git_ref: desc.git_ref.clone(),
subdir: desc.subdir.clone().or_else(|| desc.path.clone()),
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_provider_manifest() {
let json = r#"{
"name": "claude",
"owner": {
"name": "Codanna Team",
"email": "team@codanna.dev"
},
"metadata": {
"namespace": ".claude",
"settingsFile": "settings.local.json",
"version": "1.0.0"
},
"profiles": [
{
"name": "codanna",
"source": "./profiles/codanna",
"description": "Base codanna configuration",
"version": "1.0.0"
},
{
"name": "claude",
"source": "./profiles/claude",
"description": "Claude Code provider setup",
"version": "1.0.0",
"requires": ["codanna"]
}
]
}"#;
let manifest = ProviderManifest::from_json(json).unwrap();
assert_eq!(manifest.name, "claude");
assert_eq!(manifest.profiles.len(), 2);
assert_eq!(
manifest.metadata.as_ref().unwrap().namespace,
Some(".claude".to_string())
);
}
#[test]
fn test_validate_requires_name() {
let json = r#"{
"name": "",
"owner": {"name": "Test"},
"profiles": []
}"#;
assert!(ProviderManifest::from_json(json).is_err());
}
#[test]
fn test_validate_requires_profiles() {
let json = r#"{
"name": "test",
"owner": {"name": "Test"},
"profiles": []
}"#;
assert!(ProviderManifest::from_json(json).is_err());
}
#[test]
fn test_get_profile() {
let json = r#"{
"name": "claude",
"owner": {"name": "Test"},
"profiles": [
{"name": "codanna", "source": "./profiles/codanna"},
{"name": "claude", "source": "./profiles/claude"}
]
}"#;
let manifest = ProviderManifest::from_json(json).unwrap();
assert!(manifest.get_profile("codanna").is_some());
assert!(manifest.get_profile("claude").is_some());
assert!(manifest.get_profile("nonexistent").is_none());
}
#[test]
fn test_resolve_path_source() {
let source = ProviderProfileSource::Path("./profiles/codanna".to_string());
match source.resolve(Some("profiles")) {
ResolvedProfileSource::ProviderPath { relative } => {
assert_eq!(relative, "profiles/./profiles/codanna");
}
_ => panic!("Expected ProviderPath"),
}
}
#[test]
fn test_resolve_github_source() {
let source = ProviderProfileSource::Descriptor(ProviderProfileSourceDescriptor {
source: "github".to_string(),
repo: Some("codanna/profiles".to_string()),
url: None,
path: None,
subdir: Some("profiles/codanna".to_string()),
git_ref: Some("main".to_string()),
});
match source.resolve(None) {
ResolvedProfileSource::Git {
url,
git_ref,
subdir,
} => {
assert_eq!(url, "https://github.com/codanna/profiles.git");
assert_eq!(git_ref, Some("main".to_string()));
assert_eq!(subdir, Some("profiles/codanna".to_string()));
}
_ => panic!("Expected Git source"),
}
}
}