pub mod redundancy;
pub mod version_resolution;
pub mod version_resolver;
use crate::cache::Cache;
use crate::core::CcpmError;
use crate::git::GitRepo;
use crate::lockfile::{LockFile, LockedResource};
use crate::manifest::{Manifest, ResourceDependency};
use crate::source::SourceManager;
use anyhow::{Context, Result};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use self::redundancy::RedundancyDetector;
use self::version_resolver::VersionResolver;
pub struct DependencyResolver {
manifest: Manifest,
pub source_manager: SourceManager,
cache: Cache,
prepared_versions: HashMap<String, PreparedSourceVersion>,
version_resolver: VersionResolver,
}
#[derive(Clone, Debug, Default)]
struct PreparedSourceVersion {
worktree_path: PathBuf,
resolved_version: Option<String>,
resolved_commit: String,
}
#[derive(Clone, Debug)]
#[allow(dead_code)]
struct PreparedGroupDescriptor {
key: String,
source: String,
requested_version: Option<String>,
version_key: String,
}
impl DependencyResolver {
fn group_key(source: &str, version: &str) -> String {
format!("{source}::{version}")
}
fn add_or_update_lockfile_entry(
&self,
lockfile: &mut LockFile,
name: &str,
entry: LockedResource,
) {
let resource_type = self.get_resource_type(name);
match resource_type.as_str() {
"agent" => {
if let Some(existing) = lockfile.agents.iter_mut().find(|e| e.name == name) {
*existing = entry;
} else {
lockfile.agents.push(entry);
}
}
"snippet" => {
if let Some(existing) = lockfile.snippets.iter_mut().find(|e| e.name == name) {
*existing = entry;
} else {
lockfile.snippets.push(entry);
}
}
"command" => {
if let Some(existing) = lockfile.commands.iter_mut().find(|e| e.name == name) {
*existing = entry;
} else {
lockfile.commands.push(entry);
}
}
"script" => {
if let Some(existing) = lockfile.scripts.iter_mut().find(|e| e.name == name) {
*existing = entry;
} else {
lockfile.scripts.push(entry);
}
}
"hook" => {
if let Some(existing) = lockfile.hooks.iter_mut().find(|e| e.name == name) {
*existing = entry;
} else {
lockfile.hooks.push(entry);
}
}
"mcp-server" => {
if let Some(existing) = lockfile.mcp_servers.iter_mut().find(|e| e.name == name) {
*existing = entry;
} else {
lockfile.mcp_servers.push(entry);
}
}
_ => {
if let Some(existing) = lockfile.snippets.iter_mut().find(|e| e.name == name) {
*existing = entry;
} else {
lockfile.snippets.push(entry);
}
}
}
}
pub async fn pre_sync_sources(&mut self, deps: &[(String, ResourceDependency)]) -> Result<()> {
self.version_resolver.clear();
for (_, dep) in deps {
if let Some(source_name) = dep.get_source() {
let source_url =
self.source_manager
.get_source_url(source_name)
.ok_or_else(|| CcpmError::SourceNotFound {
name: source_name.to_string(),
})?;
let version = dep.get_version();
self.version_resolver
.add_version(source_name, &source_url, version);
}
}
self.version_resolver
.pre_sync_sources()
.await
.context("Failed to sync sources")?;
Ok(())
}
pub async fn get_available_versions(&self, repo_path: &Path) -> Result<Vec<String>> {
let repo = GitRepo::new(repo_path);
repo.list_tags()
.await
.with_context(|| format!("Failed to list tags from repository at {:?}", repo_path))
}
async fn create_worktrees_for_resolved_versions(
&self,
) -> Result<HashMap<String, PreparedSourceVersion>> {
let resolved_full = self.version_resolver.get_all_resolved_full().clone();
let mut prepared_versions = HashMap::new();
let mut futures = Vec::new();
for ((source_name, version_key), resolved_version) in resolved_full {
let sha = resolved_version.sha;
let resolved_ref = resolved_version.resolved_ref;
let repo_key = Self::group_key(&source_name, &version_key);
let cache_clone = self.cache.clone();
let source_name_clone = source_name.clone();
let source_url_clone = self
.source_manager
.get_source_url(&source_name)
.ok_or_else(|| CcpmError::SourceNotFound {
name: source_name.to_string(),
})?
.to_string();
let sha_clone = sha.clone();
let resolved_ref_clone = resolved_ref.clone();
let future = async move {
let worktree_path = cache_clone
.get_or_create_worktree_for_sha(
&source_name_clone,
&source_url_clone,
&sha_clone,
Some(&source_name_clone), )
.await?;
Ok::<_, anyhow::Error>((
repo_key,
PreparedSourceVersion {
worktree_path,
resolved_version: Some(resolved_ref_clone),
resolved_commit: sha_clone,
},
))
};
futures.push(future);
}
let results = futures::future::join_all(futures).await;
for result in results {
let (key, prepared) = result?;
prepared_versions.insert(key, prepared);
}
Ok(prepared_versions)
}
async fn prepare_remote_groups(&mut self, deps: &[(String, ResourceDependency)]) -> Result<()> {
self.prepared_versions.clear();
if !self.version_resolver.has_entries() {
for (_, dep) in deps {
if let Some(source_name) = dep.get_source() {
let source_url =
self.source_manager
.get_source_url(source_name)
.ok_or_else(|| CcpmError::SourceNotFound {
name: source_name.to_string(),
})?;
let version = dep.get_version();
self.version_resolver
.add_version(source_name, &source_url, version);
}
}
self.version_resolver
.pre_sync_sources()
.await
.context("Failed to sync sources")?;
}
self.version_resolver
.resolve_all()
.await
.context("Failed to resolve versions to SHAs")?;
let prepared_versions = self.create_worktrees_for_resolved_versions().await?;
self.prepared_versions.extend(prepared_versions);
Ok(())
}
pub fn new(manifest: Manifest, cache: Cache) -> Result<Self> {
let source_manager = SourceManager::from_manifest(&manifest)?;
let version_resolver = VersionResolver::new(cache.clone());
Ok(Self {
manifest,
source_manager,
cache,
prepared_versions: HashMap::new(),
version_resolver,
})
}
pub async fn new_with_global(manifest: Manifest, cache: Cache) -> Result<Self> {
let source_manager = SourceManager::from_manifest_with_global(&manifest).await?;
let version_resolver = VersionResolver::new(cache.clone());
Ok(Self {
manifest,
source_manager,
cache,
prepared_versions: HashMap::new(),
version_resolver,
})
}
#[must_use]
pub fn with_cache(manifest: Manifest, cache: Cache) -> Self {
let cache_dir = cache.get_cache_location().to_path_buf();
let source_manager = SourceManager::from_manifest_with_cache(&manifest, cache_dir);
let version_resolver = VersionResolver::new(cache.clone());
Self {
manifest,
source_manager,
cache,
prepared_versions: HashMap::new(),
version_resolver,
}
}
pub async fn resolve(&mut self) -> Result<LockFile> {
let mut lockfile = LockFile::new();
let mut resolved = HashMap::new();
for (name, url) in &self.manifest.sources {
lockfile.add_source(name.clone(), url.clone(), String::new());
}
let deps: Vec<(String, ResourceDependency)> = self
.manifest
.all_dependencies_with_mcp()
.into_iter()
.map(|(name, dep)| (name.to_string(), dep.into_owned()))
.collect();
self.prepare_remote_groups(&deps).await?;
for (name, dep) in deps.iter() {
if dep.is_pattern() {
let entries = self.resolve_pattern_dependency(name, dep).await?;
let resource_type = self.get_resource_type(name);
for entry in entries {
match resource_type.as_str() {
"agent" => {
if let Some(existing) =
lockfile.agents.iter_mut().find(|e| e.name == entry.name)
{
*existing = entry;
} else {
lockfile.agents.push(entry);
}
}
"snippet" => {
if let Some(existing) =
lockfile.snippets.iter_mut().find(|e| e.name == entry.name)
{
*existing = entry;
} else {
lockfile.snippets.push(entry);
}
}
"command" => {
if let Some(existing) =
lockfile.commands.iter_mut().find(|e| e.name == entry.name)
{
*existing = entry;
} else {
lockfile.commands.push(entry);
}
}
"script" => {
if let Some(existing) =
lockfile.scripts.iter_mut().find(|e| e.name == entry.name)
{
*existing = entry;
} else {
lockfile.scripts.push(entry);
}
}
"hook" => {
if let Some(existing) =
lockfile.hooks.iter_mut().find(|e| e.name == entry.name)
{
*existing = entry;
} else {
lockfile.hooks.push(entry);
}
}
"mcp-server" => {
if let Some(existing) = lockfile
.mcp_servers
.iter_mut()
.find(|e| e.name == entry.name)
{
*existing = entry;
} else {
lockfile.mcp_servers.push(entry);
}
}
_ => {
if let Some(existing) =
lockfile.snippets.iter_mut().find(|e| e.name == entry.name)
{
*existing = entry;
} else {
lockfile.snippets.push(entry);
}
}
}
}
} else {
let entry = self.resolve_dependency(name, dep).await?;
resolved.insert(name.to_string(), entry);
}
}
for (name, entry) in resolved {
self.add_or_update_lockfile_entry(&mut lockfile, &name, entry);
}
Ok(lockfile)
}
async fn resolve_dependency(
&mut self,
name: &str,
dep: &ResourceDependency,
) -> Result<LockedResource> {
if dep.is_pattern() {
return Err(anyhow::anyhow!(
"Pattern dependency '{}' should be resolved using resolve_pattern_dependency",
name
));
}
if dep.is_local() {
let resource_type = self.get_resource_type(name);
let filename = if let Some(custom_filename) = dep.get_filename() {
custom_filename.to_string()
} else {
let extension = match resource_type.as_str() {
"hook" | "mcp-server" => "json",
"script" => {
let path = dep.get_path();
Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("sh")
}
_ => "md",
};
format!("{}.{}", name, extension)
};
let installed_at = if let Some(custom_target) = dep.get_target() {
let custom_path = format!(
".claude/{}",
custom_target
.trim_start_matches(".claude/")
.trim_start_matches('/')
);
format!("{}/{}", custom_path, filename)
} else {
let target_dir = match resource_type.as_str() {
"agent" => &self.manifest.target.agents,
"snippet" => &self.manifest.target.snippets,
"command" => &self.manifest.target.commands,
"script" => &self.manifest.target.scripts,
"hook" => &self.manifest.target.hooks,
"mcp-server" => &self.manifest.target.mcp_servers,
_ => &self.manifest.target.snippets, };
format!("{}/{}", target_dir, filename)
};
Ok(LockedResource {
name: name.to_string(),
source: None,
url: None,
path: dep.get_path().to_string(),
version: None,
resolved_commit: None,
checksum: String::new(),
installed_at,
})
} else {
let source_name = dep.get_source().ok_or_else(|| CcpmError::ConfigError {
message: format!("Dependency '{}' has no source specified", name),
})?;
let source_url = self
.source_manager
.get_source_url(source_name)
.ok_or_else(|| CcpmError::SourceNotFound {
name: source_name.to_string(),
})?;
let version_key = dep
.get_version()
.map(|v| v.to_string())
.unwrap_or_else(|| "HEAD".to_string());
let prepared_key = Self::group_key(source_name, &version_key);
let (resolved_version, resolved_commit) =
if let Some(prepared) = self.prepared_versions.get(&prepared_key) {
(
prepared.resolved_version.clone(),
prepared.resolved_commit.clone(),
)
} else {
let deps = vec![(name.to_string(), dep.clone())];
self.prepare_remote_groups(&deps).await?;
if let Some(prepared) = self.prepared_versions.get(&prepared_key) {
(
prepared.resolved_version.clone(),
prepared.resolved_commit.clone(),
)
} else {
return Err(anyhow::anyhow!(
"Failed to prepare dependency '{}' from source '{}' @ '{}'",
name,
source_name,
version_key
));
}
};
let resource_type = self.get_resource_type(name);
let filename = if let Some(custom_filename) = dep.get_filename() {
custom_filename.to_string()
} else {
let extension = match resource_type.as_str() {
"hook" | "mcp-server" => "json",
"script" => {
let path = dep.get_path();
Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("sh")
}
_ => "md",
};
format!("{}.{}", name, extension)
};
let installed_at = if let Some(custom_target) = dep.get_target() {
let custom_path = format!(
".claude/{}",
custom_target
.trim_start_matches(".claude/")
.trim_start_matches('/')
);
format!("{}/{}", custom_path, filename)
} else {
let target_dir = match resource_type.as_str() {
"agent" => &self.manifest.target.agents,
"snippet" => &self.manifest.target.snippets,
"command" => &self.manifest.target.commands,
"script" => &self.manifest.target.scripts,
"hook" => &self.manifest.target.hooks,
"mcp-server" => &self.manifest.target.mcp_servers,
_ => &self.manifest.target.snippets, };
format!("{}/{}", target_dir, filename)
};
Ok(LockedResource {
name: name.to_string(),
source: Some(source_name.to_string()),
url: Some(source_url.clone()),
path: dep.get_path().to_string(),
version: resolved_version, resolved_commit: Some(resolved_commit),
checksum: String::new(), installed_at,
})
}
}
async fn resolve_pattern_dependency(
&mut self,
name: &str,
dep: &ResourceDependency,
) -> Result<Vec<LockedResource>> {
if !dep.is_pattern() {
return Err(anyhow::anyhow!(
"Expected pattern dependency but no glob characters found in path"
));
}
let pattern = dep.get_path();
if dep.is_local() {
let (base_path, pattern_str) = if pattern.contains('/') || pattern.contains('\\') {
let pattern_path = Path::new(pattern);
if let Some(parent) = pattern_path.parent() {
if parent.is_absolute() || parent.starts_with("..") || parent.starts_with(".") {
(
parent.to_path_buf(),
pattern_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(pattern)
.to_string(),
)
} else {
(PathBuf::from("."), pattern.to_string())
}
} else {
(PathBuf::from("."), pattern.to_string())
}
} else {
(PathBuf::from("."), pattern.to_string())
};
let pattern_resolver = crate::pattern::PatternResolver::new();
let matches = pattern_resolver.resolve(&pattern_str, &base_path)?;
let resource_type = self.get_resource_type(name);
let mut resources = Vec::new();
for matched_path in matches {
let resource_name = crate::pattern::extract_resource_name(&matched_path);
let target_dir = if let Some(custom_target) = dep.get_target() {
format!(
".claude/{}",
custom_target
.trim_start_matches(".claude/")
.trim_start_matches('/')
)
} else {
match resource_type.as_str() {
"agent" => self.manifest.target.agents.clone(),
"snippet" => self.manifest.target.snippets.clone(),
"command" => self.manifest.target.commands.clone(),
"script" => self.manifest.target.scripts.clone(),
"hook" => self.manifest.target.hooks.clone(),
"mcp-server" => self.manifest.target.mcp_servers.clone(),
_ => self.manifest.target.snippets.clone(),
}
};
let extension = matched_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("md");
let filename = format!("{}.{}", resource_name, extension);
let installed_at = format!("{}/{}", target_dir, filename);
let full_relative_path = if base_path == Path::new(".") {
matched_path.to_string_lossy().to_string()
} else {
format!("{}/{}", base_path.display(), matched_path.display())
};
resources.push(LockedResource {
name: resource_name.clone(),
source: None,
url: None,
path: full_relative_path,
version: None,
resolved_commit: None,
checksum: String::new(),
installed_at,
});
}
Ok(resources)
} else {
let source_name = dep.get_source().ok_or_else(|| CcpmError::ConfigError {
message: format!("Pattern dependency '{name}' has no source specified"),
})?;
let source_url = self
.source_manager
.get_source_url(source_name)
.ok_or_else(|| CcpmError::SourceNotFound {
name: source_name.to_string(),
})?;
let version_key = dep
.get_version()
.map(|v| v.to_string())
.unwrap_or_else(|| "HEAD".to_string());
let prepared_key = Self::group_key(source_name, &version_key);
let prepared = self
.prepared_versions
.get(&prepared_key)
.ok_or_else(|| {
anyhow::anyhow!(
"Prepared state missing for source '{}' @ '{}'. Stage 1 preparation should have populated this entry.",
source_name,
version_key
)
})?;
let repo_path = prepared.worktree_path.clone();
let resolved_version = prepared.resolved_version.clone();
let resolved_commit = prepared.resolved_commit.clone();
let pattern_resolver = crate::pattern::PatternResolver::new();
let repo_path_ref = Path::new(&repo_path);
let matches = pattern_resolver.resolve(pattern, repo_path_ref)?;
let resource_type = self.get_resource_type(name);
let mut resources = Vec::new();
for matched_path in matches {
let resource_name = crate::pattern::extract_resource_name(&matched_path);
let target_dir = if let Some(custom_target) = dep.get_target() {
format!(
".claude/{}",
custom_target
.trim_start_matches(".claude/")
.trim_start_matches('/')
)
} else {
match resource_type.as_str() {
"agent" => self.manifest.target.agents.clone(),
"snippet" => self.manifest.target.snippets.clone(),
"command" => self.manifest.target.commands.clone(),
"script" => self.manifest.target.scripts.clone(),
"hook" => self.manifest.target.hooks.clone(),
"mcp-server" => self.manifest.target.mcp_servers.clone(),
_ => self.manifest.target.snippets.clone(),
}
};
let extension = matched_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("md");
let filename = format!("{}.{}", resource_name, extension);
let installed_at = format!("{}/{}", target_dir, filename);
resources.push(LockedResource {
name: resource_name.clone(),
source: Some(source_name.to_string()),
url: Some(source_url.clone()),
path: matched_path.to_string_lossy().to_string(),
version: resolved_version.clone(), resolved_commit: Some(resolved_commit.clone()),
checksum: String::new(),
installed_at,
});
}
Ok(resources)
}
}
fn get_resource_type(&self, name: &str) -> String {
if self.manifest.agents.contains_key(name) {
"agent".to_string()
} else if self.manifest.snippets.contains_key(name) {
"snippet".to_string()
} else if self.manifest.commands.contains_key(name) {
"command".to_string()
} else if self.manifest.scripts.contains_key(name) {
"script".to_string()
} else if self.manifest.hooks.contains_key(name) {
"hook".to_string()
} else if self.manifest.mcp_servers.contains_key(name) {
"mcp-server".to_string()
} else {
"snippet".to_string() }
}
pub async fn update(
&mut self,
existing: &LockFile,
deps_to_update: Option<Vec<String>>,
) -> Result<LockFile> {
let mut lockfile = existing.clone();
let deps_to_check: HashSet<String> = if let Some(specific) = deps_to_update {
specific.into_iter().collect()
} else {
self.manifest
.all_dependencies()
.iter()
.map(|(name, _)| (*name).to_string())
.collect()
};
let deps: Vec<(String, ResourceDependency)> = self
.manifest
.all_dependencies()
.into_iter()
.map(|(name, dep)| (name.to_string(), dep.clone()))
.collect();
self.prepare_remote_groups(&deps).await?;
for (name, dep) in deps {
if !deps_to_check.contains(&name) {
continue;
}
if dep.is_pattern() {
let entries = self.resolve_pattern_dependency(&name, &dep).await?;
let resource_type = self.get_resource_type(&name);
for entry in entries {
match resource_type.as_str() {
"agent" => {
if let Some(existing) =
lockfile.agents.iter_mut().find(|e| e.name == entry.name)
{
*existing = entry;
} else {
lockfile.agents.push(entry);
}
}
"snippet" => {
if let Some(existing) =
lockfile.snippets.iter_mut().find(|e| e.name == entry.name)
{
*existing = entry;
} else {
lockfile.snippets.push(entry);
}
}
"command" => {
if let Some(existing) =
lockfile.commands.iter_mut().find(|e| e.name == entry.name)
{
*existing = entry;
} else {
lockfile.commands.push(entry);
}
}
"script" => {
if let Some(existing) =
lockfile.scripts.iter_mut().find(|e| e.name == entry.name)
{
*existing = entry;
} else {
lockfile.scripts.push(entry);
}
}
"hook" => {
if let Some(existing) =
lockfile.hooks.iter_mut().find(|e| e.name == entry.name)
{
*existing = entry;
} else {
lockfile.hooks.push(entry);
}
}
"mcp-server" => {
if let Some(existing) = lockfile
.mcp_servers
.iter_mut()
.find(|e| e.name == entry.name)
{
*existing = entry;
} else {
lockfile.mcp_servers.push(entry);
}
}
_ => {
if let Some(existing) =
lockfile.snippets.iter_mut().find(|e| e.name == entry.name)
{
*existing = entry;
} else {
lockfile.snippets.push(entry);
}
}
}
}
} else {
let entry = self.resolve_dependency(&name, &dep).await?;
self.add_or_update_lockfile_entry(&mut lockfile, &name, entry);
}
}
Ok(lockfile)
}
#[must_use]
pub fn check_redundancies(&self) -> Option<String> {
let mut detector = RedundancyDetector::new();
detector.analyze_manifest(&self.manifest);
let redundancies = detector.detect_redundancies();
if !redundancies.is_empty() {
return Some(detector.generate_redundancy_warning(&redundancies));
}
None
}
#[must_use]
pub fn check_redundancies_with_details(&self) -> Vec<redundancy::Redundancy> {
let mut detector = RedundancyDetector::new();
detector.analyze_manifest(&self.manifest);
detector.detect_redundancies()
}
pub fn verify(&mut self) -> Result<()> {
if let Some(warning) = self.check_redundancies() {
eprintln!("{warning}");
}
let deps: Vec<(String, ResourceDependency)> = self
.manifest
.all_dependencies()
.into_iter()
.map(|(name, dep)| (name.to_string(), dep.clone()))
.collect();
for (name, dep) in deps {
if dep.is_local() {
let path = Path::new(dep.get_path());
if path.is_absolute() && !path.exists() {
anyhow::bail!(
"Local dependency '{}' not found at: {}",
name,
path.display()
);
}
} else {
let source_name = dep.get_source().ok_or_else(|| CcpmError::ConfigError {
message: format!("Dependency '{name}' has no source specified"),
})?;
if !self.manifest.sources.contains_key(source_name) {
anyhow::bail!(
"Dependency '{}' references undefined source: '{}'",
name,
source_name
);
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_resolver_new() {
let manifest = Manifest::new();
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let resolver = DependencyResolver::with_cache(manifest, cache);
assert_eq!(resolver.cache.get_cache_location(), temp_dir.path());
}
#[tokio::test]
async fn test_resolve_local_dependency() {
let mut manifest = Manifest::new();
manifest.add_dependency(
"local-agent".to_string(),
ResourceDependency::Simple("../agents/local.md".to_string()),
true,
);
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let lockfile = resolver.resolve().await.unwrap();
assert_eq!(lockfile.agents.len(), 1);
let entry = &lockfile.agents[0];
assert_eq!(entry.name, "local-agent");
assert_eq!(entry.path, "../agents/local.md");
assert!(entry.source.is_none());
assert!(entry.url.is_none());
}
#[test]
fn test_check_redundancies() {
let mut manifest = Manifest::new();
manifest.add_source(
"official".to_string(),
"https://github.com/test/repo.git".to_string(),
);
manifest.add_dependency(
"agent1".to_string(),
ResourceDependency::Detailed(crate::manifest::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.add_dependency(
"agent2".to_string(),
ResourceDependency::Detailed(crate::manifest::DetailedDependency {
source: Some("official".to_string()),
path: "agents/test.md".to_string(),
version: Some("v2.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
}),
true,
);
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let resolver = DependencyResolver::with_cache(manifest, cache);
let warning = resolver.check_redundancies();
assert!(warning.is_some());
assert!(warning.unwrap().contains("Redundant dependencies detected"));
}
#[tokio::test]
async fn test_pre_sync_sources() {
if std::process::Command::new("git")
.arg("--version")
.output()
.is_err()
{
eprintln!("Skipping test: git not available");
return;
}
let temp_dir = TempDir::new().unwrap();
let repo_dir = temp_dir.path().join("test-repo");
std::fs::create_dir(&repo_dir).unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(&repo_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&repo_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&repo_dir)
.output()
.unwrap();
std::fs::create_dir_all(repo_dir.join("agents")).unwrap();
std::fs::write(
repo_dir.join("agents/test.md"),
"# Test Agent\n\nTest content",
)
.unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&repo_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(&repo_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["tag", "v1.0.0"])
.current_dir(&repo_dir)
.output()
.unwrap();
let mut manifest = Manifest::new();
let source_url = format!("file://{}", repo_dir.display());
manifest.add_source("test-source".to_string(), source_url.clone());
manifest.add_dependency(
"test-agent".to_string(),
ResourceDependency::Detailed(crate::manifest::DetailedDependency {
source: Some("test-source".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,
);
let cache_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(cache_dir.path().to_path_buf()).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest.clone(), cache);
let deps: Vec<(String, ResourceDependency)> = manifest
.all_dependencies()
.into_iter()
.map(|(name, dep)| (name.to_string(), dep.clone()))
.collect();
resolver.pre_sync_sources(&deps).await.unwrap();
assert!(
resolver.version_resolver.pending_count() > 0,
"Should have entries after pre-sync"
);
let bare_repo = resolver.version_resolver.get_bare_repo_path("test-source");
assert!(bare_repo.is_some(), "Should have bare repo path cached");
let cached_repo_path = resolver.cache.get_cache_location().join("sources");
let mut found_repo = false;
if let Ok(entries) = std::fs::read_dir(&cached_repo_path) {
for entry in entries.flatten() {
if let Some(name) = entry.file_name().to_str()
&& name.ends_with(".git")
{
found_repo = true;
break;
}
}
}
assert!(found_repo, "Repository should be cloned to cache");
resolver.version_resolver.resolve_all().await.unwrap();
let all_resolved = resolver.version_resolver.get_all_resolved();
assert!(
!all_resolved.is_empty(),
"Resolution should produce resolved versions"
);
let key = ("test-source".to_string(), "v1.0.0".to_string());
assert!(
all_resolved.contains_key(&key),
"Should have resolved v1.0.0"
);
let sha = all_resolved.get(&key).unwrap();
assert_eq!(sha.len(), 40, "SHA should be 40 characters");
}
#[test]
fn test_verify_missing_source() {
let mut manifest = Manifest::new();
manifest.add_dependency(
"remote-agent".to_string(),
ResourceDependency::Detailed(crate::manifest::DetailedDependency {
source: Some("nonexistent".to_string()),
path: "agents/test.md".to_string(),
version: None,
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
}),
true,
);
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let result = resolver.verify();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("undefined source"));
}
#[test]
fn test_get_resource_type() {
let mut manifest = Manifest::new();
manifest.add_dependency(
"agent1".to_string(),
ResourceDependency::Simple("a.md".to_string()),
true,
);
manifest.add_dependency(
"snippet1".to_string(),
ResourceDependency::Simple("s.md".to_string()),
false,
);
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let resolver = DependencyResolver::with_cache(manifest, cache);
assert_eq!(resolver.get_resource_type("agent1"), "agent");
assert_eq!(resolver.get_resource_type("snippet1"), "snippet");
}
#[tokio::test]
async fn test_resolve_with_source_dependency() {
let temp_dir = TempDir::new().unwrap();
let source_dir = temp_dir.path().join("test-source");
std::fs::create_dir_all(&source_dir).unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(&source_dir)
.output()
.expect("Failed to initialize git repository");
let agents_dir = source_dir.join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
std::fs::write(agents_dir.join("test.md"), "# Test Agent").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["tag", "v1.0.0"])
.current_dir(&source_dir)
.output()
.unwrap();
let mut manifest = Manifest::new();
let source_url = source_dir.display().to_string();
manifest.add_source("test".to_string(), source_url);
manifest.add_dependency(
"remote-agent".to_string(),
ResourceDependency::Detailed(crate::manifest::DetailedDependency {
source: Some("test".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,
);
let cache_dir = temp_dir.path().join("cache");
let cache = Cache::with_dir(cache_dir).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let result = resolver.resolve().await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_resolve_with_progress() {
let mut manifest = Manifest::new();
manifest.add_dependency(
"local".to_string(),
ResourceDependency::Simple("test.md".to_string()),
true,
);
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let lockfile = resolver.resolve().await.unwrap();
assert_eq!(lockfile.agents.len(), 1);
}
#[test]
fn test_verify_with_progress() {
let mut manifest = Manifest::new();
manifest.add_source(
"test".to_string(),
"https://github.com/test/repo.git".to_string(),
);
manifest.add_dependency(
"agent".to_string(),
ResourceDependency::Detailed(crate::manifest::DetailedDependency {
source: Some("test".to_string()),
path: "agents/test.md".to_string(),
version: None,
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
}),
true,
);
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let result = resolver.verify();
assert!(result.is_ok());
}
#[tokio::test]
async fn test_resolve_with_git_ref() {
let temp_dir = TempDir::new().unwrap();
let source_dir = temp_dir.path().join("test-source");
std::fs::create_dir_all(&source_dir).unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(&source_dir)
.output()
.expect("Failed to initialize git repository");
std::process::Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&source_dir)
.output()
.unwrap();
let agents_dir = source_dir.join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
std::fs::write(agents_dir.join("test.md"), "# Test Agent").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["branch", "-M", "main"])
.current_dir(&source_dir)
.output()
.unwrap();
let mut manifest = Manifest::new();
let source_url = source_dir.display().to_string();
manifest.add_source("test".to_string(), source_url);
manifest.add_dependency(
"git-agent".to_string(),
ResourceDependency::Detailed(crate::manifest::DetailedDependency {
source: Some("test".to_string()),
path: "agents/test.md".to_string(),
version: None,
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
}),
true,
);
let cache_dir = temp_dir.path().join("cache");
let cache = Cache::with_dir(cache_dir).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let result = resolver.resolve().await;
if let Err(e) = &result {
eprintln!("Test failed with error: {:#}", e);
}
assert!(result.is_ok());
}
#[tokio::test]
async fn test_new_with_global() {
let manifest = Manifest::new();
let cache = Cache::new().unwrap();
let result = DependencyResolver::new_with_global(manifest, cache).await;
assert!(result.is_ok());
}
#[test]
fn test_resolver_new_default() {
let manifest = Manifest::new();
let cache = Cache::new().unwrap();
let result = DependencyResolver::new(manifest, cache);
assert!(result.is_ok());
}
#[tokio::test]
async fn test_resolve_multiple_dependencies() {
let mut manifest = Manifest::new();
manifest.add_dependency(
"agent1".to_string(),
ResourceDependency::Simple("a1.md".to_string()),
true,
);
manifest.add_dependency(
"agent2".to_string(),
ResourceDependency::Simple("a2.md".to_string()),
true,
);
manifest.add_dependency(
"snippet1".to_string(),
ResourceDependency::Simple("s1.md".to_string()),
false,
);
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let lockfile = resolver.resolve().await.unwrap();
assert_eq!(lockfile.agents.len(), 2);
assert_eq!(lockfile.snippets.len(), 1);
}
#[test]
fn test_check_redundancies_no_redundancy() {
let mut manifest = Manifest::new();
manifest.add_source(
"official".to_string(),
"https://github.com/test/repo.git".to_string(),
);
manifest.add_dependency(
"agent1".to_string(),
ResourceDependency::Detailed(crate::manifest::DetailedDependency {
source: Some("official".to_string()),
path: "agents/test1.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
}),
true,
);
manifest.add_dependency(
"agent2".to_string(),
ResourceDependency::Detailed(crate::manifest::DetailedDependency {
source: Some("official".to_string()),
path: "agents/test2.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
}),
true,
);
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let resolver = DependencyResolver::with_cache(manifest, cache);
let warning = resolver.check_redundancies();
assert!(warning.is_none());
}
#[test]
fn test_verify_local_dependency() {
let mut manifest = Manifest::new();
manifest.add_dependency(
"local-agent".to_string(),
ResourceDependency::Simple("../local/agent.md".to_string()),
true,
);
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let result = resolver.verify();
assert!(result.is_ok());
}
#[tokio::test]
async fn test_resolve_with_empty_manifest() {
let manifest = Manifest::new();
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let lockfile = resolver.resolve().await.unwrap();
assert_eq!(lockfile.agents.len(), 0);
assert_eq!(lockfile.snippets.len(), 0);
assert_eq!(lockfile.sources.len(), 0);
}
#[tokio::test]
async fn test_resolve_with_custom_target() {
let mut manifest = Manifest::new();
manifest.add_dependency(
"custom-agent".to_string(),
ResourceDependency::Detailed(crate::manifest::DetailedDependency {
source: None,
path: "../test.md".to_string(),
version: None,
branch: None,
rev: None,
command: None,
args: None,
target: Some("integrations/custom".to_string()),
filename: None,
}),
true,
);
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let lockfile = resolver.resolve().await.unwrap();
assert_eq!(lockfile.agents.len(), 1);
let agent = &lockfile.agents[0];
assert_eq!(agent.name, "custom-agent");
assert!(agent.installed_at.contains(".claude/integrations/custom"));
assert_eq!(
agent.installed_at,
".claude/integrations/custom/custom-agent.md"
);
}
#[tokio::test]
async fn test_resolve_without_custom_target() {
let mut manifest = Manifest::new();
manifest.add_dependency(
"standard-agent".to_string(),
ResourceDependency::Detailed(crate::manifest::DetailedDependency {
source: None,
path: "../test.md".to_string(),
version: None,
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
}),
true,
);
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let lockfile = resolver.resolve().await.unwrap();
assert_eq!(lockfile.agents.len(), 1);
let agent = &lockfile.agents[0];
assert_eq!(agent.name, "standard-agent");
assert_eq!(agent.installed_at, ".claude/agents/standard-agent.md");
}
#[tokio::test]
async fn test_resolve_with_custom_filename() {
let mut manifest = Manifest::new();
manifest.add_dependency(
"my-agent".to_string(),
ResourceDependency::Detailed(crate::manifest::DetailedDependency {
source: None,
path: "../test.md".to_string(),
version: None,
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: Some("ai-assistant.txt".to_string()),
}),
true,
);
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let lockfile = resolver.resolve().await.unwrap();
assert_eq!(lockfile.agents.len(), 1);
let agent = &lockfile.agents[0];
assert_eq!(agent.name, "my-agent");
assert_eq!(agent.installed_at, ".claude/agents/ai-assistant.txt");
}
#[tokio::test]
async fn test_resolve_with_custom_filename_and_target() {
let mut manifest = Manifest::new();
manifest.add_dependency(
"special-tool".to_string(),
ResourceDependency::Detailed(crate::manifest::DetailedDependency {
source: None,
path: "../test.md".to_string(),
version: None,
branch: None,
rev: None,
command: None,
args: None,
target: Some("tools/ai".to_string()),
filename: Some("assistant.markdown".to_string()),
}),
true,
);
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let lockfile = resolver.resolve().await.unwrap();
assert_eq!(lockfile.agents.len(), 1);
let agent = &lockfile.agents[0];
assert_eq!(agent.name, "special-tool");
assert_eq!(agent.installed_at, ".claude/tools/ai/assistant.markdown");
}
#[tokio::test]
async fn test_resolve_script_with_custom_filename() {
let mut manifest = Manifest::new();
manifest.add_dependency(
"analyzer".to_string(),
ResourceDependency::Detailed(crate::manifest::DetailedDependency {
source: None,
path: "../scripts/data-analyzer-v3.py".to_string(),
version: None,
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: Some("analyze.py".to_string()),
}),
false, );
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let lockfile = resolver.resolve().await.unwrap();
assert_eq!(lockfile.snippets.len(), 1);
let script = &lockfile.snippets[0];
assert_eq!(script.name, "analyzer");
assert_eq!(script.installed_at, ".claude/ccpm/snippets/analyze.py");
}
#[tokio::test]
async fn test_resolve_pattern_dependency_local() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path();
let agents_dir = project_dir.join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
std::fs::write(agents_dir.join("helper.md"), "# Helper Agent").unwrap();
std::fs::write(agents_dir.join("assistant.md"), "# Assistant Agent").unwrap();
std::fs::write(agents_dir.join("tester.md"), "# Tester Agent").unwrap();
let mut manifest = Manifest::new();
manifest.add_dependency(
"local-agents".to_string(),
ResourceDependency::Simple(format!("{}/agents/*.md", project_dir.display())),
true,
);
let cache_dir = temp_dir.path().join("cache");
let cache = Cache::with_dir(cache_dir).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let lockfile = resolver.resolve().await.unwrap();
assert_eq!(lockfile.agents.len(), 3);
let agent_names: Vec<String> = lockfile.agents.iter().map(|a| a.name.clone()).collect();
assert!(agent_names.contains(&"helper".to_string()));
assert!(agent_names.contains(&"assistant".to_string()));
assert!(agent_names.contains(&"tester".to_string()));
}
#[tokio::test]
async fn test_resolve_pattern_dependency_remote() {
let temp_dir = TempDir::new().unwrap();
let source_dir = temp_dir.path().join("test-source");
std::fs::create_dir_all(&source_dir).unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(&source_dir)
.output()
.expect("Failed to initialize git repository");
std::process::Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&source_dir)
.output()
.unwrap();
let agents_dir = source_dir.join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
std::fs::write(agents_dir.join("python-linter.md"), "# Python Linter").unwrap();
std::fs::write(agents_dir.join("python-formatter.md"), "# Python Formatter").unwrap();
std::fs::write(agents_dir.join("rust-linter.md"), "# Rust Linter").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "Add agents"])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["tag", "v1.0.0"])
.current_dir(&source_dir)
.output()
.unwrap();
let mut manifest = Manifest::new();
let source_url = source_dir.display().to_string();
manifest.add_source("test".to_string(), source_url);
manifest.add_dependency(
"python-tools".to_string(),
ResourceDependency::Detailed(crate::manifest::DetailedDependency {
source: Some("test".to_string()),
path: "agents/python-*.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
}),
true, );
let cache_dir = temp_dir.path().join("cache");
let cache = Cache::with_dir(cache_dir).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let lockfile = resolver.resolve().await.unwrap();
assert_eq!(lockfile.agents.len(), 2);
let agent_names: Vec<String> = lockfile.agents.iter().map(|a| a.name.clone()).collect();
assert!(agent_names.contains(&"python-linter".to_string()));
assert!(agent_names.contains(&"python-formatter".to_string()));
assert!(!agent_names.contains(&"rust-linter".to_string()));
}
#[tokio::test]
async fn test_resolve_pattern_dependency_with_custom_target() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path();
let agents_dir = project_dir.join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
std::fs::write(agents_dir.join("helper.md"), "# Helper Agent").unwrap();
std::fs::write(agents_dir.join("assistant.md"), "# Assistant Agent").unwrap();
let mut manifest = Manifest::new();
manifest.add_dependency(
"custom-agents".to_string(),
ResourceDependency::Detailed(crate::manifest::DetailedDependency {
source: None,
path: format!("{}/agents/*.md", project_dir.display()),
version: None,
branch: None,
rev: None,
command: None,
args: None,
target: Some("custom/agents".to_string()),
filename: None,
}),
true,
);
let cache_dir = temp_dir.path().join("cache");
let cache = Cache::with_dir(cache_dir).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let lockfile = resolver.resolve().await.unwrap();
assert_eq!(lockfile.agents.len(), 2);
for agent in &lockfile.agents {
assert!(agent.installed_at.starts_with(".claude/custom/agents/"));
}
let agent_names: Vec<String> = lockfile.agents.iter().map(|a| a.name.clone()).collect();
assert!(agent_names.contains(&"helper".to_string()));
assert!(agent_names.contains(&"assistant".to_string()));
}
#[tokio::test]
async fn test_update_specific_dependencies() {
let temp_dir = TempDir::new().unwrap();
let source_dir = temp_dir.path().join("test-source");
std::fs::create_dir_all(&source_dir).unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(&source_dir)
.output()
.expect("Failed to initialize git repository");
std::process::Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&source_dir)
.output()
.unwrap();
let agents_dir = source_dir.join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
std::fs::write(agents_dir.join("agent1.md"), "# Agent 1 v1").unwrap();
std::fs::write(agents_dir.join("agent2.md"), "# Agent 2 v1").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "Initial"])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["tag", "v1.0.0"])
.current_dir(&source_dir)
.output()
.unwrap();
std::fs::write(agents_dir.join("agent1.md"), "# Agent 1 v2").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "Update agent1"])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["tag", "v2.0.0"])
.current_dir(&source_dir)
.output()
.unwrap();
let mut manifest = Manifest::new();
let source_url = source_dir.display().to_string();
manifest.add_source("test".to_string(), source_url);
manifest.add_dependency(
"agent1".to_string(),
ResourceDependency::Detailed(crate::manifest::DetailedDependency {
source: Some("test".to_string()),
path: "agents/agent1.md".to_string(),
version: Some("v1.0.0".to_string()), branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
}),
true,
);
manifest.add_dependency(
"agent2".to_string(),
ResourceDependency::Detailed(crate::manifest::DetailedDependency {
source: Some("test".to_string()),
path: "agents/agent2.md".to_string(),
version: Some("v1.0.0".to_string()), branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
}),
true,
);
let cache_dir = temp_dir.path().join("cache");
let cache = Cache::with_dir(cache_dir.clone()).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest.clone(), cache);
let initial_lockfile = resolver.resolve().await.unwrap();
assert_eq!(initial_lockfile.agents.len(), 2);
let mut updated_manifest = Manifest::new();
updated_manifest.add_source("test".to_string(), source_dir.display().to_string());
updated_manifest.add_dependency(
"agent1".to_string(),
ResourceDependency::Detailed(crate::manifest::DetailedDependency {
source: Some("test".to_string()),
path: "agents/agent1.md".to_string(),
version: Some("v2.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
}),
true,
);
updated_manifest.add_dependency(
"agent2".to_string(),
ResourceDependency::Detailed(crate::manifest::DetailedDependency {
source: Some("test".to_string()),
path: "agents/agent2.md".to_string(),
version: Some("v1.0.0".to_string()), branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
}),
true,
);
let cache2 = Cache::with_dir(cache_dir).unwrap();
let mut resolver2 = DependencyResolver::with_cache(updated_manifest, cache2);
let updated_lockfile = resolver2
.update(&initial_lockfile, Some(vec!["agent1".to_string()]))
.await
.unwrap();
let agent1 = updated_lockfile
.agents
.iter()
.find(|a| a.name == "agent1")
.unwrap();
assert_eq!(agent1.version.as_ref().unwrap(), "v2.0.0");
let agent2 = updated_lockfile
.agents
.iter()
.find(|a| a.name == "agent2")
.unwrap();
assert_eq!(agent2.version.as_ref().unwrap(), "v1.0.0");
}
#[tokio::test]
async fn test_update_all_dependencies() {
let mut manifest = Manifest::new();
manifest.add_dependency(
"local1".to_string(),
ResourceDependency::Simple("../a1.md".to_string()),
true,
);
manifest.add_dependency(
"local2".to_string(),
ResourceDependency::Simple("../a2.md".to_string()),
true,
);
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest.clone(), cache);
let initial_lockfile = resolver.resolve().await.unwrap();
assert_eq!(initial_lockfile.agents.len(), 2);
let cache2 = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let mut resolver2 = DependencyResolver::with_cache(manifest, cache2);
let updated_lockfile = resolver2.update(&initial_lockfile, None).await.unwrap();
assert_eq!(updated_lockfile.agents.len(), 2);
}
#[tokio::test]
async fn test_resolve_hooks_resource_type() {
let mut manifest = Manifest::new();
manifest.hooks.insert(
"pre-commit".to_string(),
ResourceDependency::Simple("../hooks/pre-commit.json".to_string()),
);
manifest.hooks.insert(
"post-commit".to_string(),
ResourceDependency::Simple("../hooks/post-commit.json".to_string()),
);
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let lockfile = resolver.resolve().await.unwrap();
assert_eq!(lockfile.hooks.len(), 2);
for hook in &lockfile.hooks {
assert!(hook.installed_at.contains(".claude/ccpm/hooks/"));
assert!(hook.installed_at.ends_with(".json"));
}
}
#[tokio::test]
async fn test_resolve_scripts_resource_type() {
let mut manifest = Manifest::new();
manifest.scripts.insert(
"build".to_string(),
ResourceDependency::Simple("../scripts/build.sh".to_string()),
);
manifest.scripts.insert(
"test".to_string(),
ResourceDependency::Simple("../scripts/test.py".to_string()),
);
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let lockfile = resolver.resolve().await.unwrap();
assert_eq!(lockfile.scripts.len(), 2);
let build_script = lockfile.scripts.iter().find(|s| s.name == "build").unwrap();
assert!(build_script.installed_at.ends_with("build.sh"));
let test_script = lockfile.scripts.iter().find(|s| s.name == "test").unwrap();
assert!(test_script.installed_at.ends_with("test.py"));
}
#[tokio::test]
async fn test_resolve_mcp_servers_resource_type() {
let mut manifest = Manifest::new();
manifest.mcp_servers.insert(
"filesystem".to_string(),
ResourceDependency::Simple("../mcp/filesystem.json".to_string()),
);
manifest.mcp_servers.insert(
"database".to_string(),
ResourceDependency::Simple("../mcp/database.json".to_string()),
);
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let lockfile = resolver.resolve().await.unwrap();
assert_eq!(lockfile.mcp_servers.len(), 2);
for server in &lockfile.mcp_servers {
assert!(server.installed_at.contains(".claude/ccpm/mcp-servers/"));
assert!(server.installed_at.ends_with(".json"));
}
}
#[tokio::test]
async fn test_resolve_commands_resource_type() {
let mut manifest = Manifest::new();
manifest.commands.insert(
"deploy".to_string(),
ResourceDependency::Simple("../commands/deploy.md".to_string()),
);
manifest.commands.insert(
"lint".to_string(),
ResourceDependency::Simple("../commands/lint.md".to_string()),
);
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let lockfile = resolver.resolve().await.unwrap();
assert_eq!(lockfile.commands.len(), 2);
for command in &lockfile.commands {
assert!(command.installed_at.contains(".claude/commands/"));
assert!(command.installed_at.ends_with(".md"));
}
}
#[tokio::test]
async fn test_checkout_version_with_constraint() {
let temp_dir = TempDir::new().unwrap();
let source_dir = temp_dir.path().join("test-source");
std::fs::create_dir_all(&source_dir).unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(&source_dir)
.output()
.expect("Failed to initialize git repository");
std::process::Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&source_dir)
.output()
.unwrap();
let test_file = source_dir.join("test.txt");
std::fs::write(&test_file, "v1.0.0").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "v1.0.0"])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["tag", "v1.0.0"])
.current_dir(&source_dir)
.output()
.unwrap();
std::fs::write(&test_file, "v1.1.0").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "v1.1.0"])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["tag", "v1.1.0"])
.current_dir(&source_dir)
.output()
.unwrap();
std::fs::write(&test_file, "v1.2.0").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "v1.2.0"])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["tag", "v1.2.0"])
.current_dir(&source_dir)
.output()
.unwrap();
std::fs::write(&test_file, "v2.0.0").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "v2.0.0"])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["tag", "v2.0.0"])
.current_dir(&source_dir)
.output()
.unwrap();
let mut manifest = Manifest::new();
let source_url = source_dir.display().to_string();
manifest.add_source("test".to_string(), source_url);
manifest.add_dependency(
"constrained-dep".to_string(),
ResourceDependency::Detailed(crate::manifest::DetailedDependency {
source: Some("test".to_string()),
path: "test.txt".to_string(),
version: Some("^1.0.0".to_string()), branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
}),
true,
);
let cache_dir = temp_dir.path().join("cache");
let cache = Cache::with_dir(cache_dir).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let lockfile = resolver.resolve().await.unwrap();
assert_eq!(lockfile.agents.len(), 1);
let agent = &lockfile.agents[0];
assert_eq!(agent.version.as_ref().unwrap(), "v1.2.0");
}
#[tokio::test]
async fn test_verify_absolute_path_error() {
let mut manifest = Manifest::new();
let nonexistent_path = if cfg!(windows) {
"C:\\nonexistent\\path\\agent.md"
} else {
"/nonexistent/path/agent.md"
};
manifest.add_dependency(
"missing-agent".to_string(),
ResourceDependency::Simple(nonexistent_path.to_string()),
true,
);
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let result = resolver.verify();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[tokio::test]
async fn test_resolve_pattern_dependency_error() {
let mut manifest = Manifest::new();
manifest.add_dependency(
"pattern-dep".to_string(),
ResourceDependency::Detailed(crate::manifest::DetailedDependency {
source: Some("nonexistent".to_string()),
path: "agents/*.md".to_string(), version: None,
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
}),
true,
);
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let result = resolver.resolve().await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_checkout_version_with_branch() {
let temp_dir = TempDir::new().unwrap();
let source_dir = temp_dir.path().join("test-source");
std::fs::create_dir_all(&source_dir).unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(&source_dir)
.output()
.expect("Failed to initialize git repository");
std::process::Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&source_dir)
.output()
.unwrap();
let test_file = source_dir.join("test.txt");
std::fs::write(&test_file, "main").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "Initial"])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["checkout", "-b", "develop"])
.current_dir(&source_dir)
.output()
.unwrap();
std::fs::write(&test_file, "develop").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "Develop commit"])
.current_dir(&source_dir)
.output()
.unwrap();
let mut manifest = Manifest::new();
let source_url = source_dir.display().to_string();
manifest.add_source("test".to_string(), source_url);
manifest.add_dependency(
"branch-dep".to_string(),
ResourceDependency::Detailed(crate::manifest::DetailedDependency {
source: Some("test".to_string()),
path: "test.txt".to_string(),
version: Some("develop".to_string()), branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
}),
true,
);
let cache_dir = temp_dir.path().join("cache");
let cache = Cache::with_dir(cache_dir).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let lockfile = resolver.resolve().await.unwrap();
assert_eq!(lockfile.agents.len(), 1);
let agent = &lockfile.agents[0];
assert!(agent.resolved_commit.is_some());
}
#[tokio::test]
async fn test_checkout_version_with_commit_hash() {
let temp_dir = TempDir::new().unwrap();
let source_dir = temp_dir.path().join("test-source");
std::fs::create_dir_all(&source_dir).unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(&source_dir)
.output()
.expect("Failed to initialize git repository");
std::process::Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&source_dir)
.output()
.unwrap();
let test_file = source_dir.join("test.txt");
std::fs::write(&test_file, "content").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "Test commit"])
.current_dir(&source_dir)
.output()
.unwrap();
let output = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&source_dir)
.output()
.unwrap();
let commit_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
let mut manifest = Manifest::new();
let source_url = source_dir.display().to_string();
manifest.add_source("test".to_string(), source_url);
manifest.add_dependency(
"commit-dep".to_string(),
ResourceDependency::Detailed(crate::manifest::DetailedDependency {
source: Some("test".to_string()),
path: "test.txt".to_string(),
version: Some(commit_hash[..7].to_string()), branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
}),
true,
);
let cache_dir = temp_dir.path().join("cache");
let cache = Cache::with_dir(cache_dir).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let lockfile = resolver.resolve().await.unwrap();
assert_eq!(lockfile.agents.len(), 1);
let agent = &lockfile.agents[0];
assert!(agent.resolved_commit.is_some());
assert!(
agent
.resolved_commit
.as_ref()
.unwrap()
.starts_with(&commit_hash[..7])
);
}
#[test]
fn test_check_redundancies_with_details() {
let mut manifest = Manifest::new();
manifest.add_source(
"official".to_string(),
"https://github.com/test/repo.git".to_string(),
);
manifest.add_dependency(
"helper-v1".to_string(),
ResourceDependency::Detailed(crate::manifest::DetailedDependency {
source: Some("official".to_string()),
path: "agents/helper.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
}),
true,
);
manifest.add_dependency(
"helper-v2".to_string(),
ResourceDependency::Detailed(crate::manifest::DetailedDependency {
source: Some("official".to_string()),
path: "agents/helper.md".to_string(),
version: Some("v2.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
}),
true,
);
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let resolver = DependencyResolver::with_cache(manifest, cache);
let redundancies = resolver.check_redundancies_with_details();
assert!(!redundancies.is_empty());
let redundancy = &redundancies[0];
assert_eq!(redundancy.source_file, "official:agents/helper.md");
assert_eq!(redundancy.usages.len(), 2);
}
#[tokio::test]
async fn test_mixed_resource_types() {
let mut manifest = Manifest::new();
manifest.add_dependency(
"agent1".to_string(),
ResourceDependency::Simple("../agents/a1.md".to_string()),
true,
);
manifest.scripts.insert(
"build".to_string(),
ResourceDependency::Simple("../scripts/build.sh".to_string()),
);
manifest.hooks.insert(
"pre-commit".to_string(),
ResourceDependency::Simple("../hooks/pre-commit.json".to_string()),
);
manifest.commands.insert(
"deploy".to_string(),
ResourceDependency::Simple("../commands/deploy.md".to_string()),
);
manifest.mcp_servers.insert(
"filesystem".to_string(),
ResourceDependency::Simple("../mcp/filesystem.json".to_string()),
);
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let lockfile = resolver.resolve().await.unwrap();
assert_eq!(lockfile.agents.len(), 1);
assert_eq!(lockfile.scripts.len(), 1);
assert_eq!(lockfile.hooks.len(), 1);
assert_eq!(lockfile.commands.len(), 1);
assert_eq!(lockfile.mcp_servers.len(), 1);
}
}