#![allow(dead_code)]
use std::path::Path;
use anyhow::{Context, Result};
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct PluginManifest {
pub name: String,
pub version: String,
pub description: String,
pub author_name: Option<String>,
pub author_url: Option<String>,
pub homepage: Option<String>,
pub repository: Option<String>,
pub license: Option<String>,
pub keywords: Vec<String>,
pub is_compatibility_mode: bool,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct MarketplaceManifest {
pub owner_name: String,
pub owner_url: Option<String>,
pub plugins: Vec<MarketplacePlugin>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct MarketplacePlugin {
pub name: String,
pub source: String,
pub description: String,
}
pub fn resolve_manifest(root_dir: &Path) -> Result<Option<PluginManifest>> {
let collet_dir = root_dir.join(".collet-plugin");
let toml_path = collet_dir.join("plugin.toml");
if toml_path.exists() {
let manifest = parse_plugin_toml(&toml_path)?;
return Ok(Some(manifest));
}
let collet_json = collet_dir.join("plugin.json");
if collet_json.exists() {
let mut manifest = parse_plugin_json_inner(&collet_json)?;
manifest.is_compatibility_mode = false;
return Ok(Some(manifest));
}
let claude_json = root_dir.join(".claude-plugin").join("plugin.json");
if claude_json.exists() {
tracing::info!(
path = %claude_json.display(),
"Loading plugin manifest in Claude Code compatibility mode"
);
let mut manifest = parse_plugin_json_inner(&claude_json)?;
manifest.is_compatibility_mode = true;
return Ok(Some(manifest));
}
Ok(None)
}
fn parse_plugin_toml(path: &Path) -> Result<PluginManifest> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let mut name = String::new();
let mut version = "0.0.0".to_string();
let mut description = String::new();
let mut author_name: Option<String> = None;
let mut author_url: Option<String> = None;
let mut homepage: Option<String> = None;
let mut repository: Option<String> = None;
let mut license: Option<String> = None;
let mut keywords: Vec<String> = Vec::new();
let mut in_plugin_table = false;
let mut in_author_table = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
if trimmed == "[plugin]" {
in_plugin_table = true;
in_author_table = false;
} else if trimmed == "[plugin.author]" {
in_plugin_table = false;
in_author_table = true;
} else {
in_plugin_table = false;
in_author_table = false;
}
continue;
}
if in_author_table {
if let Some((key, value)) = parse_toml_kv(trimmed) {
match key.as_str() {
"name" => author_name = Some(value),
"url" => author_url = Some(value),
_ => {}
}
}
continue;
}
if !in_plugin_table {
continue;
}
if let Some((key, value)) = parse_toml_kv(trimmed) {
match key.as_str() {
"name" => name = value,
"version" => version = value,
"description" => description = value,
"homepage" => homepage = Some(value),
"repository" => repository = Some(value),
"license" => license = Some(value),
"author_name" => author_name = Some(value),
"author_url" => author_url = Some(value),
"keywords" => keywords = parse_toml_array(&value),
_ => {} }
}
}
if name.is_empty() {
anyhow::bail!("plugin.toml missing 'name' field");
}
Ok(PluginManifest {
name,
version,
description,
author_name,
author_url,
homepage,
repository,
license,
keywords,
is_compatibility_mode: false,
})
}
fn parse_toml_kv(line: &str) -> Option<(String, String)> {
let eq_pos = line.find('=')?;
let key = line[..eq_pos].trim().to_string();
let value_part = line[eq_pos + 1..].trim();
if value_part.is_empty() {
return None;
}
if value_part.starts_with('[') {
return Some((key, value_part.to_string()));
}
if ((value_part.starts_with('"') && value_part.ends_with('"'))
|| (value_part.starts_with('\'') && value_part.ends_with('\'')))
&& value_part.len() >= 2
{
return Some((key, value_part[1..value_part.len() - 1].to_string()));
}
Some((key, value_part.to_string()))
}
fn parse_toml_array(value: &str) -> Vec<String> {
let inner = value
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.unwrap_or("");
inner
.split(',')
.map(|s| s.trim().trim_matches('"').trim_matches('\''))
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect()
}
pub fn parse_plugin_json(path: &Path) -> Result<PluginManifest> {
let mut manifest = parse_plugin_json_inner(path)?;
manifest.is_compatibility_mode = path.to_string_lossy().contains(".claude-plugin");
Ok(manifest)
}
fn parse_plugin_json_inner(path: &Path) -> Result<PluginManifest> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let raw: serde_json::Value = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse {}", path.display()))?;
let name = raw
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let version = raw
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("0.0.0")
.to_string();
let description = raw
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let author_name = raw
.get("author")
.and_then(|a| a.get("name"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let author_url = raw
.get("author")
.and_then(|a| a.get("url"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let homepage = raw
.get("homepage")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let repository = raw
.get("repository")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let license = raw
.get("license")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let keywords = raw
.get("keywords")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
if name.is_empty() {
anyhow::bail!("plugin.json missing 'name' field");
}
Ok(PluginManifest {
name,
version,
description,
author_name,
author_url,
homepage,
repository,
license,
keywords,
is_compatibility_mode: false, })
}
#[allow(dead_code)]
pub fn resolve_marketplace(root_dir: &Path) -> Result<Option<MarketplaceManifest>> {
let collet_path = root_dir.join(".collet-plugin").join("marketplace.json");
if collet_path.exists() {
return Ok(Some(parse_marketplace_json(&collet_path)?));
}
let claude_path = root_dir.join(".claude-plugin").join("marketplace.json");
if claude_path.exists() {
return Ok(Some(parse_marketplace_json(&claude_path)?));
}
Ok(None)
}
#[allow(dead_code)]
pub fn parse_marketplace_json(path: &Path) -> Result<MarketplaceManifest> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let raw: serde_json::Value = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse {}", path.display()))?;
let owner_name = raw
.get("owner")
.and_then(|o| o.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let owner_url = raw
.get("owner")
.and_then(|o| o.get("url"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let plugins = raw
.get("plugins")
.and_then(|v| v.as_array())
.map(|arr| {
let mut result = Vec::new();
for p in arr {
let name = match p.get("name").and_then(|v| v.as_str()) {
Some(n) => n.to_string(),
None => continue,
};
let source = p
.get("source")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let description = p
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
result.push(MarketplacePlugin {
name,
source,
description,
});
}
result
})
.unwrap_or_default();
Ok(MarketplaceManifest {
owner_name,
owner_url,
plugins,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_parse_plugin_json_collet() {
let dir = tempfile::tempdir().unwrap();
let collet_dir = dir.path().join(".collet-plugin");
fs::create_dir_all(&collet_dir).unwrap();
let path = collet_dir.join("plugin.json");
fs::write(
&path,
serde_json::json!({
"name": "epic",
"version": "0.1.0",
"description": "A test plugin",
"author": {
"name": "epicsagas",
"url": "https://github.com/epicsagas"
},
"homepage": "https://github.com/epicsagas/epic-harness",
"repository": "https://github.com/epicsagas/epic-harness",
"license": "Apache-2.0",
"keywords": ["harness", "tdd"]
})
.to_string(),
)
.unwrap();
let manifest = parse_plugin_json(&path).unwrap();
assert_eq!(manifest.name, "epic");
assert_eq!(manifest.version, "0.1.0");
assert_eq!(manifest.description, "A test plugin");
assert_eq!(manifest.author_name.as_deref(), Some("epicsagas"));
assert_eq!(manifest.license.as_deref(), Some("Apache-2.0"));
assert_eq!(manifest.keywords, vec!["harness", "tdd"]);
assert!(!manifest.is_compatibility_mode);
}
#[test]
fn test_parse_plugin_json_claude_compat() {
let dir = tempfile::tempdir().unwrap();
let claude_dir = dir.path().join(".claude-plugin");
fs::create_dir_all(&claude_dir).unwrap();
let path = claude_dir.join("plugin.json");
fs::write(
&path,
serde_json::json!({
"name": "claude-plugin",
"version": "1.0.0",
"description": "A Claude Code plugin",
})
.to_string(),
)
.unwrap();
let manifest = parse_plugin_json(&path).unwrap();
assert_eq!(manifest.name, "claude-plugin");
assert!(manifest.is_compatibility_mode);
}
#[test]
fn test_parse_plugin_toml() {
let dir = tempfile::tempdir().unwrap();
let collet_dir = dir.path().join(".collet-plugin");
fs::create_dir_all(&collet_dir).unwrap();
let path = collet_dir.join("plugin.toml");
fs::write(
&path,
r#"[plugin]
name = "my-toml-plugin"
version = "2.0.0"
description = "TOML-based plugin"
homepage = "https://example.com"
license = "MIT"
keywords = ["testing", "tdd"]
[plugin.author]
name = "Dev"
url = "https://github.com/dev"
"#,
)
.unwrap();
let manifest = parse_plugin_toml(&path).unwrap();
assert_eq!(manifest.name, "my-toml-plugin");
assert_eq!(manifest.version, "2.0.0");
assert_eq!(manifest.description, "TOML-based plugin");
assert_eq!(manifest.homepage.as_deref(), Some("https://example.com"));
assert_eq!(manifest.license.as_deref(), Some("MIT"));
assert_eq!(manifest.keywords, vec!["testing", "tdd"]);
assert_eq!(manifest.author_name.as_deref(), Some("Dev"));
assert!(!manifest.is_compatibility_mode);
}
#[test]
fn test_parse_plugin_toml_missing_name() {
let dir = tempfile::tempdir().unwrap();
let collet_dir = dir.path().join(".collet-plugin");
fs::create_dir_all(&collet_dir).unwrap();
let path = collet_dir.join("plugin.toml");
fs::write(&path, "[plugin]\nversion = \"1.0.0\"\n").unwrap();
assert!(parse_plugin_toml(&path).is_err());
}
#[test]
fn test_resolve_manifest_priority() {
let dir = tempfile::tempdir().unwrap();
let plugin_root = dir.path().join("priority-test");
fs::create_dir_all(plugin_root.join(".collet-plugin")).unwrap();
fs::create_dir_all(plugin_root.join(".claude-plugin")).unwrap();
fs::write(
plugin_root.join(".collet-plugin").join("plugin.toml"),
"[plugin]\nname = \"collet-native\"\nversion = \"1.0.0\"\n",
)
.unwrap();
fs::write(
plugin_root.join(".claude-plugin").join("plugin.json"),
serde_json::json!({ "name": "claude-fallback", "version": "0.1.0" }).to_string(),
)
.unwrap();
let manifest = resolve_manifest(&plugin_root).unwrap().unwrap();
assert_eq!(manifest.name, "collet-native");
assert!(!manifest.is_compatibility_mode);
}
#[test]
fn test_resolve_manifest_fallback() {
let dir = tempfile::tempdir().unwrap();
let plugin_root = dir.path().join("fallback-test");
fs::create_dir_all(plugin_root.join(".claude-plugin")).unwrap();
fs::write(
plugin_root.join(".claude-plugin").join("plugin.json"),
serde_json::json!({ "name": "claude-only", "version": "0.5.0" }).to_string(),
)
.unwrap();
let manifest = resolve_manifest(&plugin_root).unwrap().unwrap();
assert_eq!(manifest.name, "claude-only");
assert!(manifest.is_compatibility_mode);
}
#[test]
fn test_resolve_manifest_none() {
let dir = tempfile::tempdir().unwrap();
let plugin_root = dir.path().join("no-manifest");
fs::create_dir_all(&plugin_root).unwrap();
assert!(resolve_manifest(&plugin_root).unwrap().is_none());
}
#[test]
fn test_parse_plugin_json_minimal() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("plugin.json");
fs::write(
&path,
serde_json::json!({
"name": "minimal",
})
.to_string(),
)
.unwrap();
let manifest = parse_plugin_json(&path).unwrap();
assert_eq!(manifest.name, "minimal");
assert_eq!(manifest.version, "0.0.0");
assert!(manifest.keywords.is_empty());
}
#[test]
fn test_parse_plugin_json_missing_name() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("plugin.json");
fs::write(
&path,
serde_json::json!({
"version": "1.0.0",
})
.to_string(),
)
.unwrap();
assert!(parse_plugin_json(&path).is_err());
}
#[test]
fn test_parse_marketplace_json() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("marketplace.json");
fs::write(
&path,
serde_json::json!({
"name": "epicsagas",
"owner": {
"name": "epicsagas",
"url": "https://github.com/epicsagas"
},
"plugins": [{
"name": "epic",
"source": "./",
"description": "A test plugin"
}]
})
.to_string(),
)
.unwrap();
let manifest = parse_marketplace_json(&path).unwrap();
assert_eq!(manifest.owner_name, "epicsagas");
assert_eq!(manifest.plugins.len(), 1);
assert_eq!(manifest.plugins[0].name, "epic");
}
}