use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub sources: HashMap<String, String>,
#[serde(default)]
pub target: TargetConfig,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub agents: HashMap<String, ResourceDependency>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub snippets: HashMap<String, ResourceDependency>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub commands: HashMap<String, ResourceDependency>,
#[serde(
default,
skip_serializing_if = "HashMap::is_empty",
rename = "mcp-servers"
)]
pub mcp_servers: HashMap<String, ResourceDependency>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub scripts: HashMap<String, ResourceDependency>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub hooks: HashMap<String, ResourceDependency>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TargetConfig {
#[serde(default = "default_agents_dir")]
pub agents: String,
#[serde(default = "default_snippets_dir")]
pub snippets: String,
#[serde(default = "default_commands_dir")]
pub commands: String,
#[serde(default = "default_mcp_servers_dir", rename = "mcp-servers")]
pub mcp_servers: String,
#[serde(default = "default_scripts_dir")]
pub scripts: String,
#[serde(default = "default_hooks_dir")]
pub hooks: String,
#[serde(default = "default_gitignore")]
pub gitignore: bool,
}
impl Default for TargetConfig {
fn default() -> Self {
Self {
agents: default_agents_dir(),
snippets: default_snippets_dir(),
commands: default_commands_dir(),
mcp_servers: default_mcp_servers_dir(),
scripts: default_scripts_dir(),
hooks: default_hooks_dir(),
gitignore: default_gitignore(),
}
}
}
fn default_agents_dir() -> String {
".claude/agents".to_string()
}
fn default_snippets_dir() -> String {
".claude/ccpm/snippets".to_string()
}
fn default_commands_dir() -> String {
".claude/commands".to_string()
}
fn default_mcp_servers_dir() -> String {
".claude/ccpm/mcp-servers".to_string()
}
fn default_scripts_dir() -> String {
".claude/ccpm/scripts".to_string()
}
fn default_hooks_dir() -> String {
".claude/ccpm/hooks".to_string()
}
fn default_gitignore() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ResourceDependency {
Simple(String),
Detailed(DetailedDependency),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetailedDependency {
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rev: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub args: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
}
impl Manifest {
#[must_use]
pub fn new() -> Self {
Self {
sources: HashMap::new(),
target: TargetConfig::default(),
agents: HashMap::new(),
snippets: HashMap::new(),
commands: HashMap::new(),
mcp_servers: HashMap::new(),
scripts: HashMap::new(),
hooks: HashMap::new(),
}
}
pub fn load(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path).with_context(|| {
format!(
"Cannot read manifest file: {}\n\n\
Possible causes:\n\
- File doesn't exist or has been moved\n\
- Permission denied (check file ownership)\n\
- File is locked by another process",
path.display()
)
})?;
let manifest: Self = toml::from_str(&content)
.map_err(|e| crate::core::CcpmError::ManifestParseError {
file: path.display().to_string(),
reason: e.to_string(),
})
.with_context(|| {
format!(
"Invalid TOML syntax in manifest file: {}\n\n\
Common TOML syntax errors:\n\
- Missing quotes around strings\n\
- Unmatched brackets [ ] or braces {{ }}\n\
- Invalid characters in keys or values\n\
- Incorrect indentation or structure",
path.display()
)
})?;
manifest.validate()?;
Ok(manifest)
}
pub fn save(&self, path: &Path) -> Result<()> {
let content = toml::to_string_pretty(self)
.with_context(|| "Failed to serialize manifest data to TOML format")?;
std::fs::write(path, content).with_context(|| {
format!(
"Cannot write manifest file: {}\n\n\
Possible causes:\n\
- Permission denied (try running with elevated permissions)\n\
- Directory doesn't exist\n\
- Disk is full or read-only\n\
- File is locked by another process",
path.display()
)
})?;
Ok(())
}
pub fn validate(&self) -> Result<()> {
for (name, dep) in self.all_dependencies() {
if dep.get_path().is_empty() {
return Err(crate::core::CcpmError::ManifestValidationError {
reason: format!("Missing required field 'path' for dependency '{name}'"),
}
.into());
}
if dep.is_pattern() {
crate::pattern::validate_pattern_safety(dep.get_path()).map_err(|e| {
crate::core::CcpmError::ManifestValidationError {
reason: format!("Invalid pattern in dependency '{}': {}", name, e),
}
})?;
}
if let Some(source) = dep.get_source() {
if !self.sources.contains_key(source) {
return Err(crate::core::CcpmError::SourceNotFound {
name: source.to_string(),
}
.into());
}
let source_url = self.sources.get(source).unwrap();
let _is_local_source = source_url.starts_with('/')
|| source_url.starts_with("./")
|| source_url.starts_with("../");
} else {
if !dep.is_pattern() {
let path = dep.get_path();
let is_plain_dir =
path.starts_with('/') || path.starts_with("./") || path.starts_with("../");
if is_plain_dir && dep.get_version().is_some() {
return Err(crate::core::CcpmError::ManifestValidationError {
reason: format!(
"Version specified for plain directory dependency '{name}' with path '{path}'. \n\
Plain directory dependencies do not support versions. \n\
Remove the 'version' field or use a git source instead."
),
}
.into());
}
}
}
}
let mut seen_deps: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for (name, dep) in self.all_dependencies() {
if let Some(version) = dep.get_version() {
if let Some(existing_version) = seen_deps.get(name) {
if existing_version != version {
return Err(crate::core::CcpmError::ManifestValidationError {
reason: format!("Version conflict for dependency '{name}': found versions '{existing_version}' and '{version}'"),
}
.into());
}
} else {
seen_deps.insert(name.to_string(), version.to_string());
}
}
}
for (name, url) in &self.sources {
let expanded_url = expand_url(url)?;
if !expanded_url.starts_with("http://")
&& !expanded_url.starts_with("https://")
&& !expanded_url.starts_with("git@")
&& !expanded_url.starts_with("file://")
&& !expanded_url.starts_with('/')
&& !expanded_url.starts_with("./")
&& !expanded_url.starts_with("../")
{
return Err(crate::core::CcpmError::ManifestValidationError {
reason: format!(
"Source '{name}' has invalid URL: '{url}'. Must be HTTP(S), SSH (git@...), or file:// URL"
),
}
.into());
}
if expanded_url.starts_with('/')
|| expanded_url.starts_with("./")
|| expanded_url.starts_with("../")
{
return Err(crate::core::CcpmError::ManifestValidationError {
reason: format!(
"Plain directory path '{url}' cannot be used as source '{name}'. \n\
Sources must be git repositories. Use one of:\n\
- Remote URL: https://github.com/owner/repo.git\n\
- Local git repo: file:///absolute/path/to/repo\n\
- Or use direct path dependencies without a source"
),
}
.into());
}
}
let mut normalized_names: std::collections::HashSet<String> =
std::collections::HashSet::new();
for (name, _) in self.all_dependencies() {
let normalized = name.to_lowercase();
if !normalized_names.insert(normalized.clone()) {
for (other_name, _) in self.all_dependencies() {
if other_name != name && other_name.to_lowercase() == normalized {
return Err(crate::core::CcpmError::ManifestValidationError {
reason: format!(
"Case conflict: '{}' and '{}' would map to the same file on case-insensitive filesystems. To ensure portability across platforms, resource names must be case-insensitively unique.",
name, other_name
),
}
.into());
}
}
}
}
Ok(())
}
pub fn get_dependencies(
&self,
resource_type: crate::core::ResourceType,
) -> Option<&HashMap<String, ResourceDependency>> {
use crate::core::ResourceType;
match resource_type {
ResourceType::Agent => Some(&self.agents),
ResourceType::Snippet => Some(&self.snippets),
ResourceType::Command => Some(&self.commands),
ResourceType::Script => Some(&self.scripts),
ResourceType::Hook => Some(&self.hooks),
ResourceType::McpServer => Some(&self.mcp_servers),
}
}
pub fn get_target_dir(&self, resource_type: crate::core::ResourceType) -> &str {
use crate::core::ResourceType;
match resource_type {
ResourceType::Agent => &self.target.agents,
ResourceType::Snippet => &self.target.snippets,
ResourceType::Command => &self.target.commands,
ResourceType::Script => &self.target.scripts,
ResourceType::Hook => &self.target.hooks,
ResourceType::McpServer => &self.target.mcp_servers,
}
}
#[must_use]
pub fn all_dependencies(&self) -> Vec<(&str, &ResourceDependency)> {
let mut deps = Vec::new();
for resource_type in crate::core::ResourceType::all() {
if let Some(type_deps) = self.get_dependencies(*resource_type) {
for (name, dep) in type_deps {
deps.push((name.as_str(), dep));
}
}
}
deps
}
#[must_use]
pub fn all_dependencies_with_mcp(
&self,
) -> Vec<(&str, std::borrow::Cow<'_, ResourceDependency>)> {
let mut deps = Vec::new();
for resource_type in crate::core::ResourceType::all() {
if let Some(type_deps) = self.get_dependencies(*resource_type) {
for (name, dep) in type_deps {
deps.push((name.as_str(), std::borrow::Cow::Borrowed(dep)));
}
}
}
deps
}
#[must_use]
pub fn has_dependency(&self, name: &str) -> bool {
self.agents.contains_key(name)
|| self.snippets.contains_key(name)
|| self.commands.contains_key(name)
}
#[must_use]
pub fn get_dependency(&self, name: &str) -> Option<&ResourceDependency> {
self.agents
.get(name)
.or_else(|| self.snippets.get(name))
.or_else(|| self.commands.get(name))
}
pub fn find_dependency(&self, name: &str) -> Option<&ResourceDependency> {
self.get_dependency(name)
}
pub fn add_source(&mut self, name: String, url: String) {
self.sources.insert(name, url);
}
pub fn add_dependency(&mut self, name: String, dep: ResourceDependency, is_agent: bool) {
if is_agent {
self.agents.insert(name, dep);
} else {
self.snippets.insert(name, dep);
}
}
pub fn add_typed_dependency(
&mut self,
name: String,
dep: ResourceDependency,
resource_type: crate::core::ResourceType,
) {
match resource_type {
crate::core::ResourceType::Agent => {
self.agents.insert(name, dep);
}
crate::core::ResourceType::Snippet => {
self.snippets.insert(name, dep);
}
crate::core::ResourceType::Command => {
self.commands.insert(name, dep);
}
crate::core::ResourceType::McpServer => {
panic!("Use add_mcp_server() for MCP server dependencies");
}
crate::core::ResourceType::Script => {
self.scripts.insert(name, dep);
}
crate::core::ResourceType::Hook => {
self.hooks.insert(name, dep);
}
}
}
pub fn add_mcp_server(&mut self, name: String, dependency: ResourceDependency) {
self.mcp_servers.insert(name, dependency);
}
}
impl ResourceDependency {
#[must_use]
pub fn get_source(&self) -> Option<&str> {
match self {
ResourceDependency::Simple(_) => None,
ResourceDependency::Detailed(d) => d.source.as_deref(),
}
}
#[must_use]
pub fn get_target(&self) -> Option<&str> {
match self {
ResourceDependency::Simple(_) => None,
ResourceDependency::Detailed(d) => d.target.as_deref(),
}
}
#[must_use]
pub fn get_filename(&self) -> Option<&str> {
match self {
ResourceDependency::Simple(_) => None,
ResourceDependency::Detailed(d) => d.filename.as_deref(),
}
}
#[must_use]
pub fn get_path(&self) -> &str {
match self {
ResourceDependency::Simple(path) => path,
ResourceDependency::Detailed(d) => &d.path,
}
}
#[must_use]
pub fn is_pattern(&self) -> bool {
let path = self.get_path();
path.contains('*') || path.contains('?') || path.contains('[')
}
#[must_use]
pub fn get_version(&self) -> Option<&str> {
match self {
ResourceDependency::Simple(_) => None,
ResourceDependency::Detailed(d) => {
d.rev
.as_deref()
.or(d.branch.as_deref())
.or(d.version.as_deref())
}
}
}
#[must_use]
pub fn is_local(&self) -> bool {
self.get_source().is_none()
}
}
impl Default for Manifest {
fn default() -> Self {
Self::new()
}
}
fn expand_url(url: &str) -> Result<String> {
if url.starts_with("http://")
|| url.starts_with("https://")
|| url.starts_with("git@")
|| url.starts_with("file://")
{
return Ok(url.to_string());
}
if url.contains('/') || url.contains('\\') || url.starts_with('~') || url.contains('$') {
match crate::utils::platform::resolve_path(url) {
Ok(expanded_path) => {
let path_str = expanded_path.to_string_lossy();
if expanded_path.is_absolute() {
Ok(format!("file://{path_str}"))
} else {
Ok(format!(
"file://{}",
std::env::current_dir()?
.join(expanded_path)
.to_string_lossy()
))
}
}
Err(_) => {
Ok(url.to_string())
}
}
} else {
Ok(url.to_string())
}
}
pub fn find_manifest() -> Result<PathBuf> {
let current = std::env::current_dir()
.context("Cannot determine current working directory. This may indicate a permission issue or corrupted filesystem")?;
find_manifest_from(current)
}
pub fn find_manifest_with_optional(explicit_path: Option<PathBuf>) -> Result<PathBuf> {
match explicit_path {
Some(path) => {
if path.exists() {
Ok(path)
} else {
Err(crate::core::CcpmError::ManifestNotFound.into())
}
}
None => find_manifest(),
}
}
pub fn find_manifest_from(mut current: PathBuf) -> Result<PathBuf> {
loop {
let manifest_path = current.join("ccpm.toml");
if manifest_path.exists() {
return Ok(manifest_path);
}
if !current.pop() {
return Err(crate::core::CcpmError::ManifestNotFound.into());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_manifest_new() {
let manifest = Manifest::new();
assert!(manifest.sources.is_empty());
assert!(manifest.agents.is_empty());
assert!(manifest.snippets.is_empty());
assert!(manifest.commands.is_empty());
assert!(manifest.mcp_servers.is_empty());
}
#[test]
fn test_manifest_load_save() {
let temp = tempdir().unwrap();
let manifest_path = temp.path().join("ccpm.toml");
let mut manifest = Manifest::new();
manifest.add_source(
"official".to_string(),
"https://github.com/example-org/ccpm-official.git".to_string(),
);
manifest.add_dependency(
"test-agent".to_string(),
ResourceDependency::Detailed(DetailedDependency {
source: Some("official".to_string()),
path: "agents/test.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
}),
true,
);
manifest.save(&manifest_path).unwrap();
let loaded = Manifest::load(&manifest_path).unwrap();
assert_eq!(loaded.sources.len(), 1);
assert_eq!(loaded.agents.len(), 1);
assert!(loaded.has_dependency("test-agent"));
}
#[test]
fn test_manifest_validation() {
let mut manifest = Manifest::new();
manifest.add_dependency(
"local-agent".to_string(),
ResourceDependency::Simple("../local/agent.md".to_string()),
true,
);
assert!(manifest.validate().is_ok());
manifest.add_dependency(
"remote-agent".to_string(),
ResourceDependency::Detailed(DetailedDependency {
source: Some("undefined".to_string()),
path: "agent.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
}),
true,
);
assert!(manifest.validate().is_err());
manifest.add_source(
"undefined".to_string(),
"https://github.com/test/repo.git".to_string(),
);
assert!(manifest.validate().is_ok());
}
#[test]
fn test_dependency_helpers() {
let simple_dep = ResourceDependency::Simple("path/to/file.md".to_string());
assert_eq!(simple_dep.get_path(), "path/to/file.md");
assert!(simple_dep.get_source().is_none());
assert!(simple_dep.get_version().is_none());
assert!(simple_dep.is_local());
let detailed_dep = ResourceDependency::Detailed(DetailedDependency {
source: Some("official".to_string()),
path: "agents/test.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
});
assert_eq!(detailed_dep.get_path(), "agents/test.md");
assert_eq!(detailed_dep.get_source(), Some("official"));
assert_eq!(detailed_dep.get_version(), Some("v1.0.0"));
assert!(!detailed_dep.is_local());
}
#[test]
fn test_all_dependencies() {
let mut manifest = Manifest::new();
manifest.add_dependency(
"agent1".to_string(),
ResourceDependency::Simple("a1.md".to_string()),
true,
);
manifest.add_dependency(
"snippet1".to_string(),
ResourceDependency::Simple("s1.md".to_string()),
false,
);
let all_deps = manifest.all_dependencies();
assert_eq!(all_deps.len(), 2);
}
#[test]
fn test_source_url_validation() {
let mut manifest = Manifest::new();
manifest.add_source(
"http".to_string(),
"http://github.com/test/repo.git".to_string(),
);
manifest.add_source(
"https".to_string(),
"https://github.com/test/repo.git".to_string(),
);
manifest.add_source(
"ssh".to_string(),
"git@github.com:test/repo.git".to_string(),
);
assert!(manifest.validate().is_ok());
manifest.add_source("invalid".to_string(), "not-a-url".to_string());
let result = manifest.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("invalid URL"));
}
#[test]
fn test_manifest_commands() {
let mut manifest = Manifest::new();
manifest.add_typed_dependency(
"build-command".to_string(),
ResourceDependency::Simple("commands/build.md".to_string()),
crate::core::ResourceType::Command,
);
assert!(manifest.commands.contains_key("build-command"));
assert_eq!(manifest.commands.len(), 1);
assert!(manifest.has_dependency("build-command"));
let dep = manifest.get_dependency("build-command");
assert!(dep.is_some());
assert_eq!(dep.unwrap().get_path(), "commands/build.md");
}
#[test]
fn test_manifest_all_dependencies_with_commands() {
let mut manifest = Manifest::new();
manifest.add_typed_dependency(
"agent1".to_string(),
ResourceDependency::Simple("a1.md".to_string()),
crate::core::ResourceType::Agent,
);
manifest.add_typed_dependency(
"snippet1".to_string(),
ResourceDependency::Simple("s1.md".to_string()),
crate::core::ResourceType::Snippet,
);
manifest.add_typed_dependency(
"command1".to_string(),
ResourceDependency::Simple("c1.md".to_string()),
crate::core::ResourceType::Command,
);
let all_deps = manifest.all_dependencies();
assert_eq!(all_deps.len(), 3);
assert!(manifest.agents.contains_key("agent1"));
assert!(manifest.snippets.contains_key("snippet1"));
assert!(manifest.commands.contains_key("command1"));
}
#[test]
fn test_manifest_save_load_commands() {
let temp = tempdir().unwrap();
let manifest_path = temp.path().join("ccpm.toml");
let mut manifest = Manifest::new();
manifest.add_source(
"community".to_string(),
"https://github.com/example/community.git".to_string(),
);
manifest.add_typed_dependency(
"deploy".to_string(),
ResourceDependency::Detailed(DetailedDependency {
source: Some("community".to_string()),
path: "commands/deploy.md".to_string(),
version: Some("v2.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
}),
crate::core::ResourceType::Command,
);
manifest.save(&manifest_path).unwrap();
let loaded = Manifest::load(&manifest_path).unwrap();
assert_eq!(loaded.commands.len(), 1);
assert!(loaded.commands.contains_key("deploy"));
assert!(loaded.has_dependency("deploy"));
let dep = loaded.get_dependency("deploy").unwrap();
assert_eq!(dep.get_path(), "commands/deploy.md");
assert_eq!(dep.get_version(), Some("v2.0.0"));
}
#[test]
fn test_target_config_commands_dir() {
let config = TargetConfig::default();
assert_eq!(config.commands, ".claude/commands");
let mut manifest = Manifest::new();
manifest.target.commands = "custom/commands".to_string();
assert_eq!(manifest.target.commands, "custom/commands");
}
#[test]
fn test_mcp_servers() {
let mut manifest = Manifest::new();
manifest.add_mcp_server(
"test-server".to_string(),
ResourceDependency::Detailed(DetailedDependency {
source: Some("npm".to_string()),
path: "mcp-servers/test-server.json".to_string(),
version: Some("latest".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
}),
);
assert_eq!(manifest.mcp_servers.len(), 1);
assert!(manifest.mcp_servers.contains_key("test-server"));
let server = &manifest.mcp_servers["test-server"];
assert_eq!(server.get_source(), Some("npm"));
assert_eq!(server.get_path(), "mcp-servers/test-server.json");
assert_eq!(server.get_version(), Some("latest"));
}
#[test]
fn test_manifest_save_load_mcp_servers() {
let temp = tempdir().unwrap();
let manifest_path = temp.path().join("ccpm.toml");
let mut manifest = Manifest::new();
manifest.add_source("npm".to_string(), "https://registry.npmjs.org".to_string());
manifest.add_mcp_server(
"postgres".to_string(),
ResourceDependency::Simple("../local/mcp-servers/postgres.json".to_string()),
);
manifest.save(&manifest_path).unwrap();
let loaded = Manifest::load(&manifest_path).unwrap();
assert_eq!(loaded.mcp_servers.len(), 1);
assert!(loaded.mcp_servers.contains_key("postgres"));
let server = &loaded.mcp_servers["postgres"];
assert_eq!(server.get_path(), "../local/mcp-servers/postgres.json");
}
#[test]
fn test_target_config_mcp_servers_dir() {
let config = TargetConfig::default();
assert_eq!(config.mcp_servers, ".claude/ccpm/mcp-servers");
let mut manifest = Manifest::new();
manifest.target.mcp_servers = "custom/mcp".to_string();
assert_eq!(manifest.target.mcp_servers, "custom/mcp");
}
#[test]
fn test_dependency_with_custom_target() {
let dep = ResourceDependency::Detailed(DetailedDependency {
source: Some("official".to_string()),
path: "agents/tool.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: Some("custom/tools".to_string()),
filename: None,
});
assert_eq!(dep.get_target(), Some("custom/tools"));
assert_eq!(dep.get_source(), Some("official"));
assert_eq!(dep.get_path(), "agents/tool.md");
}
#[test]
fn test_dependency_without_custom_target() {
let dep = ResourceDependency::Detailed(DetailedDependency {
source: Some("official".to_string()),
path: "agents/tool.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
});
assert!(dep.get_target().is_none());
}
#[test]
fn test_simple_dependency_no_custom_target() {
let dep = ResourceDependency::Simple("../local/file.md".to_string());
assert!(dep.get_target().is_none());
}
#[test]
fn test_save_load_dependency_with_custom_target() {
let temp = tempdir().unwrap();
let manifest_path = temp.path().join("ccpm.toml");
let mut manifest = Manifest::new();
manifest.add_source(
"official".to_string(),
"https://github.com/example/official.git".to_string(),
);
manifest.add_typed_dependency(
"special-agent".to_string(),
ResourceDependency::Detailed(DetailedDependency {
source: Some("official".to_string()),
path: "agents/special.md".to_string(),
version: Some("v1.0.0".to_string()),
target: Some("integrations/ai".to_string()),
branch: None,
rev: None,
command: None,
args: None,
filename: None,
}),
crate::core::ResourceType::Agent,
);
manifest.save(&manifest_path).unwrap();
let loaded = Manifest::load(&manifest_path).unwrap();
assert_eq!(loaded.agents.len(), 1);
assert!(loaded.agents.contains_key("special-agent"));
let dep = loaded.get_dependency("special-agent").unwrap();
assert_eq!(dep.get_target(), Some("integrations/ai"));
assert_eq!(dep.get_path(), "agents/special.md");
}
#[test]
fn test_dependency_with_custom_filename() {
let dep = ResourceDependency::Detailed(DetailedDependency {
source: Some("official".to_string()),
path: "agents/tool.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: Some("ai-assistant.md".to_string()),
});
assert_eq!(dep.get_filename(), Some("ai-assistant.md"));
assert_eq!(dep.get_source(), Some("official"));
assert_eq!(dep.get_path(), "agents/tool.md");
}
#[test]
fn test_dependency_without_custom_filename() {
let dep = ResourceDependency::Detailed(DetailedDependency {
source: Some("official".to_string()),
path: "agents/tool.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
});
assert!(dep.get_filename().is_none());
}
#[test]
fn test_simple_dependency_no_custom_filename() {
let dep = ResourceDependency::Simple("../local/file.md".to_string());
assert!(dep.get_filename().is_none());
}
#[test]
fn test_save_load_dependency_with_custom_filename() {
let temp = tempdir().unwrap();
let manifest_path = temp.path().join("ccpm.toml");
let mut manifest = Manifest::new();
manifest.add_source(
"official".to_string(),
"https://github.com/example/official.git".to_string(),
);
manifest.add_typed_dependency(
"my-agent".to_string(),
ResourceDependency::Detailed(DetailedDependency {
source: Some("official".to_string()),
path: "agents/complex-name.md".to_string(),
version: Some("v1.0.0".to_string()),
target: None,
filename: Some("simple-name.txt".to_string()),
branch: None,
rev: None,
command: None,
args: None,
}),
crate::core::ResourceType::Agent,
);
manifest.save(&manifest_path).unwrap();
let loaded = Manifest::load(&manifest_path).unwrap();
assert_eq!(loaded.agents.len(), 1);
assert!(loaded.agents.contains_key("my-agent"));
let dep = loaded.get_dependency("my-agent").unwrap();
assert_eq!(dep.get_filename(), Some("simple-name.txt"));
assert_eq!(dep.get_path(), "agents/complex-name.md");
}
#[test]
fn test_pattern_dependency() {
let dep = ResourceDependency::Detailed(DetailedDependency {
source: Some("repo".to_string()),
path: "agents/**/*.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
});
assert!(dep.is_pattern());
assert_eq!(dep.get_path(), "agents/**/*.md");
assert!(!dep.is_local());
}
#[test]
fn test_pattern_dependency_validation() {
let mut manifest = Manifest::new();
manifest.sources.insert(
"repo".to_string(),
"https://github.com/example/repo.git".to_string(),
);
manifest.agents.insert(
"ai-agents".to_string(),
ResourceDependency::Detailed(DetailedDependency {
source: Some("repo".to_string()),
path: "agents/ai/*.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
}),
);
assert!(manifest.validate().is_ok());
manifest.agents.insert(
"regular".to_string(),
ResourceDependency::Detailed(DetailedDependency {
source: Some("repo".to_string()),
path: "agents/test.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
}),
);
let result = manifest.validate();
assert!(result.is_ok());
}
#[test]
fn test_pattern_dependency_with_path_traversal() {
let mut manifest = Manifest::new();
manifest.sources.insert(
"repo".to_string(),
"https://github.com/example/repo.git".to_string(),
);
manifest.agents.insert(
"unsafe".to_string(),
ResourceDependency::Detailed(DetailedDependency {
source: Some("repo".to_string()),
path: "../../../etc/*.conf".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
}),
);
let result = manifest.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Invalid pattern"));
}
#[test]
fn test_dependency_with_both_target_and_filename() {
let dep = ResourceDependency::Detailed(DetailedDependency {
source: Some("official".to_string()),
path: "agents/tool.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: Some("tools/ai".to_string()),
filename: Some("assistant.markdown".to_string()),
});
assert_eq!(dep.get_target(), Some("tools/ai"));
assert_eq!(dep.get_filename(), Some("assistant.markdown"));
}
}