pub mod dependency_graph;
pub mod redundancy;
pub mod version_resolution;
pub mod version_resolver;
use crate::cache::Cache;
use crate::core::AgpmError;
use crate::git::GitRepo;
use crate::lockfile::{LockFile, LockedResource};
use crate::manifest::{DependencySpec, DetailedDependency, Manifest, ResourceDependency};
use crate::metadata::MetadataExtractor;
use crate::source::SourceManager;
use crate::version::conflict::ConflictDetector;
use anyhow::{Context, Result};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use self::dependency_graph::{DependencyGraph, DependencyNode};
use self::version_resolver::VersionResolver;
type ResourceKey = (crate::core::ResourceType, String, Option<String>);
type ResourceInfo = (Option<String>, Option<String>);
pub struct DependencyResolver {
manifest: Manifest,
pub source_manager: SourceManager,
cache: Cache,
prepared_versions: HashMap<String, PreparedSourceVersion>,
version_resolver: VersionResolver,
dependency_map: HashMap<(crate::core::ResourceType, String, Option<String>), Vec<String>>,
transitive_types: HashMap<(String, Option<String>), crate::core::ResourceType>,
conflict_detector: ConflictDetector,
}
#[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 resources = lockfile.get_resources_mut(entry.resource_type);
if let Some(existing) =
resources.iter_mut().find(|e| e.name == entry.name && e.source == entry.source)
{
*existing = entry;
} else {
resources.push(entry);
}
}
fn detect_target_conflicts(&self, lockfile: &LockFile) -> Result<()> {
use std::collections::HashMap;
let mut path_map: HashMap<(String, Option<String>), Vec<String>> = HashMap::new();
let all_resources: Vec<(&str, &LockedResource)> = lockfile
.agents
.iter()
.map(|r| (r.name.as_str(), r))
.chain(lockfile.snippets.iter().map(|r| (r.name.as_str(), r)))
.chain(lockfile.commands.iter().map(|r| (r.name.as_str(), r)))
.chain(lockfile.scripts.iter().map(|r| (r.name.as_str(), r)))
.collect();
for (name, resource) in &all_resources {
let key = (resource.installed_at.clone(), resource.resolved_commit.clone());
path_map.entry(key).or_default().push((*name).to_string());
}
let mut path_only_map: HashMap<String, Vec<(&str, &LockedResource)>> = HashMap::new();
for (name, resource) in &all_resources {
path_only_map.entry(resource.installed_at.clone()).or_default().push((name, resource));
}
let mut conflicts: Vec<(String, Vec<String>)> = Vec::new();
for (path, resources) in path_only_map {
if resources.len() > 1 {
let commits: std::collections::HashSet<_> =
resources.iter().map(|(_, r)| &r.resolved_commit).collect();
if commits.len() > 1 {
let names: Vec<String> =
resources.iter().map(|(n, _)| (*n).to_string()).collect();
conflicts.push((path, names));
}
}
}
if !conflicts.is_empty() {
let mut error_msg = String::from(
"Target path conflicts detected:\n\n\
Multiple dependencies resolve to the same installation path.\n\
This would cause files to overwrite each other.\n\n",
);
for (path, names) in conflicts {
error_msg.push_str(&format!(
" Path: {}\n Conflicts: {}\n\n",
path,
names.join(", ")
));
}
error_msg.push_str(
"To resolve:\n\
1. Use different dependency names for different versions\n\
2. Use custom 'target' field to specify different installation paths\n\
3. Ensure pattern dependencies don't overlap with single-file dependencies",
);
return Err(anyhow::anyhow!(error_msg));
}
Ok(())
}
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(|| {
AgpmError::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(|| AgpmError::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(|| {
AgpmError::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);
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(|| {
AgpmError::SourceNotFound {
name: source_name.to_string(),
}
})?;
if crate::utils::is_local_path(&source_url) {
let version_key = dep.get_version().unwrap_or("HEAD");
let group_key = Self::group_key(source_name, version_key);
self.prepared_versions.insert(
group_key,
PreparedSourceVersion {
worktree_path: PathBuf::from(&source_url),
resolved_version: Some("local".to_string()),
resolved_commit: String::new(), },
);
}
}
}
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,
dependency_map: HashMap::new(),
transitive_types: HashMap::new(),
conflict_detector: ConflictDetector::new(),
})
}
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,
dependency_map: HashMap::new(),
transitive_types: HashMap::new(),
conflict_detector: ConflictDetector::new(),
})
}
#[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,
dependency_map: HashMap::new(),
transitive_types: HashMap::new(),
conflict_detector: ConflictDetector::new(),
}
}
async fn resolve_transitive_dependencies(
&mut self,
base_deps: &[(String, ResourceDependency)],
enable_transitive: bool,
) -> Result<Vec<(String, ResourceDependency)>> {
self.dependency_map.clear();
self.transitive_types.clear();
if !enable_transitive {
return Ok(base_deps.to_vec());
}
let mut graph = DependencyGraph::new();
let mut all_deps: HashMap<
(crate::core::ResourceType, String, Option<String>),
ResourceDependency,
> = HashMap::new();
let mut processed: HashSet<(crate::core::ResourceType, String, Option<String>)> =
HashSet::new();
let mut queue: Vec<(String, ResourceDependency, Option<crate::core::ResourceType>)> =
Vec::new();
for (name, dep) in base_deps {
let resource_type = self.get_resource_type(name);
let source = dep.get_source().map(std::string::ToString::to_string);
queue.push((name.clone(), dep.clone(), Some(resource_type)));
all_deps.insert((resource_type, name.clone(), source), dep.clone());
}
while let Some((name, dep, resource_type)) = queue.pop() {
let source = dep.get_source().map(std::string::ToString::to_string);
let resource_type = resource_type
.unwrap_or_else(|| self.get_resource_type_with_source(&name, source.as_deref()));
let key = (resource_type, name.clone(), source.clone());
if processed.contains(&key) {
continue;
}
processed.insert(key.clone());
if dep.is_pattern() {
continue;
}
let content = match self.fetch_resource_content(&name, &dep).await {
Ok(content) => content,
Err(e) => {
eprintln!(
"Warning: Failed to fetch resource '{name}' for transitive dependency extraction: {e}"
);
continue;
}
};
let path = PathBuf::from(dep.get_path());
let metadata = MetadataExtractor::extract(&path, &content)?;
if let Some(deps_map) = metadata.dependencies {
if matches!(dep, ResourceDependency::Simple(_)) {
eprintln!(
"Warning: Resource '{}' at '{}' declares transitive dependencies, but path-only dependencies do not support this.",
name,
dep.get_path()
);
eprintln!(
" To enable transitive dependency resolution, create a local source with 'agpm add source <name> <path>'"
);
eprintln!(
" then reference this resource using the source instead of a direct path."
);
continue; }
for (dep_resource_type_str, dep_specs) in deps_map {
let dep_resource_type: crate::core::ResourceType =
dep_resource_type_str.parse().unwrap_or(crate::core::ResourceType::Snippet);
for dep_spec in dep_specs {
let trans_dep = self.spec_to_dependency(&dep, &dep_spec)?;
let trans_name = self.generate_dependency_name(&dep_spec.path);
let trans_source =
trans_dep.get_source().map(std::string::ToString::to_string);
let from_node =
DependencyNode::with_source(resource_type, &name, source.clone());
let to_node = DependencyNode::with_source(
dep_resource_type,
&trans_name,
trans_source.clone(),
);
graph.add_dependency(from_node.clone(), to_node.clone());
let from_key = (resource_type, name.clone(), source.clone());
let dep_ref = format!("{dep_resource_type}/{trans_name}");
self.dependency_map.entry(from_key).or_default().push(dep_ref);
let type_key = (trans_name.clone(), trans_source.clone());
self.transitive_types.insert(type_key, dep_resource_type);
self.add_to_conflict_detector(&trans_name, &trans_dep, &name);
let trans_key =
(dep_resource_type, trans_name.clone(), trans_source.clone());
if let Some(existing_dep) = all_deps.get(&trans_key) {
let resolved_dep = self.resolve_version_conflict(
&trans_name,
existing_dep,
&trans_dep,
&name, )?;
all_deps.insert(trans_key.clone(), resolved_dep);
} else {
all_deps.insert(trans_key.clone(), trans_dep.clone());
queue.push((trans_name, trans_dep, Some(dep_resource_type)));
}
}
}
}
}
graph.detect_cycles()?;
let ordered_nodes = graph.topological_order()?;
let mut result = Vec::new();
let mut added_keys = HashSet::new();
for node in ordered_nodes {
for (key, dep) in &all_deps {
if key.0 == node.resource_type && key.1 == node.name && key.2 == node.source {
result.push((node.name.clone(), dep.clone()));
added_keys.insert(key.clone());
break; }
}
}
for (key, dep) in all_deps {
if !added_keys.contains(&key) {
result.push((key.1.clone(), dep.clone()));
}
}
Ok(result)
}
async fn fetch_resource_content(
&mut self,
_name: &str,
dep: &ResourceDependency,
) -> Result<String> {
match dep {
ResourceDependency::Simple(path) => {
let full_path = PathBuf::from(path);
std::fs::read_to_string(&full_path)
.with_context(|| format!("Failed to read local file: {}", full_path.display()))
}
ResourceDependency::Detailed(detailed) => {
if let Some(source_name) = &detailed.source {
let source_url = self
.source_manager
.get_source_url(source_name)
.ok_or_else(|| anyhow::anyhow!("Source '{source_name}' not found"))?;
if crate::utils::is_local_path(&source_url) {
let file_path = PathBuf::from(&source_url).join(&detailed.path);
std::fs::read_to_string(&file_path).with_context(|| {
format!("Failed to read local file: {}", file_path.display())
})
} else {
let version = dep.get_version().unwrap_or("main").to_string();
let sha = if let Some(prepared) =
self.prepared_versions.get(&Self::group_key(source_name, &version))
{
prepared.resolved_commit.clone()
} else {
self.version_resolver.add_version(
source_name,
&source_url,
Some(&version),
);
self.version_resolver.resolve_all().await?;
self.version_resolver
.get_resolved_sha(source_name, &version)
.ok_or_else(|| {
anyhow::anyhow!(
"Failed to resolve version for {source_name} @ {version}"
)
})?
};
let worktree_path = self
.cache
.get_or_create_worktree_for_sha(source_name, &source_url, &sha, None)
.await?;
let file_path = worktree_path.join(&detailed.path);
std::fs::read_to_string(&file_path).with_context(|| {
format!("Failed to read file from worktree: {}", file_path.display())
})
}
} else {
let full_path = PathBuf::from(&detailed.path);
std::fs::read_to_string(&full_path).with_context(|| {
format!("Failed to read local file: {}", full_path.display())
})
}
}
}
}
fn spec_to_dependency(
&self,
parent: &ResourceDependency,
spec: &DependencySpec,
) -> Result<ResourceDependency> {
match parent {
ResourceDependency::Simple(_) => {
Err(anyhow::anyhow!(
"Transitive dependencies are not supported for path-only dependencies"
))
}
ResourceDependency::Detailed(parent_detail) => {
Ok(ResourceDependency::Detailed(Box::new(DetailedDependency {
source: parent_detail.source.clone(),
path: spec.path.clone(),
version: spec.version.clone().or_else(|| parent_detail.version.clone()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None, tool: parent_detail.tool.clone(),
})))
}
}
}
fn generate_dependency_name(&self, path: &str) -> String {
Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or(path).to_string()
}
pub async fn resolve(&mut self) -> Result<LockFile> {
self.resolve_with_options(true).await
}
pub async fn resolve_with_options(&mut self, enable_transitive: bool) -> Result<LockFile> {
let mut lockfile = LockFile::new();
for (name, url) in &self.manifest.sources {
lockfile.add_source(name.clone(), url.clone(), String::new());
}
let base_deps: Vec<(String, ResourceDependency)> = self
.manifest
.all_dependencies_with_mcp()
.into_iter()
.map(|(name, dep)| (name.to_string(), dep.into_owned()))
.collect();
for (name, dep) in &base_deps {
self.add_to_conflict_detector(name, dep, "manifest");
}
self.prepare_remote_groups(&base_deps).await?;
let deps = self.resolve_transitive_dependencies(&base_deps, enable_transitive).await?;
for (name, dep) in &deps {
if dep.is_pattern() {
let entries = self.resolve_pattern_dependency(name, dep).await?;
let source = dep.get_source();
let resource_type = self.get_resource_type_with_source(name, source);
for entry in entries {
match resource_type {
crate::core::ResourceType::Agent => {
if let Some(existing) = lockfile
.agents
.iter_mut()
.find(|e| e.name == entry.name && e.source == entry.source)
{
*existing = entry;
} else {
lockfile.agents.push(entry);
}
}
crate::core::ResourceType::Snippet => {
if let Some(existing) = lockfile
.snippets
.iter_mut()
.find(|e| e.name == entry.name && e.source == entry.source)
{
*existing = entry;
} else {
lockfile.snippets.push(entry);
}
}
crate::core::ResourceType::Command => {
if let Some(existing) = lockfile
.commands
.iter_mut()
.find(|e| e.name == entry.name && e.source == entry.source)
{
*existing = entry;
} else {
lockfile.commands.push(entry);
}
}
crate::core::ResourceType::Script => {
if let Some(existing) = lockfile
.scripts
.iter_mut()
.find(|e| e.name == entry.name && e.source == entry.source)
{
*existing = entry;
} else {
lockfile.scripts.push(entry);
}
}
crate::core::ResourceType::Hook => {
if let Some(existing) = lockfile
.hooks
.iter_mut()
.find(|e| e.name == entry.name && e.source == entry.source)
{
*existing = entry;
} else {
lockfile.hooks.push(entry);
}
}
crate::core::ResourceType::McpServer => {
if let Some(existing) = lockfile
.mcp_servers
.iter_mut()
.find(|e| e.name == entry.name && e.source == entry.source)
{
*existing = entry;
} else {
lockfile.mcp_servers.push(entry);
}
}
}
}
} else {
let entry = self.resolve_dependency(name, dep).await?;
self.add_or_update_lockfile_entry(&mut lockfile, name, entry);
}
}
let conflicts = self.conflict_detector.detect_conflicts();
if !conflicts.is_empty() {
let mut error_msg = String::from("Version conflicts detected:\n\n");
for conflict in &conflicts {
error_msg.push_str(&format!("{conflict}\n"));
}
return Err(AgpmError::Other {
message: error_msg,
}
.into());
}
self.add_version_to_dependencies(&mut lockfile)?;
self.detect_target_conflicts(&lockfile)?;
Ok(lockfile)
}
async fn resolve_dependency(
&mut self,
name: &str,
dep: &ResourceDependency,
) -> Result<LockedResource> {
if dep.is_pattern() {
return Err(anyhow::anyhow!(
"Pattern dependency '{name}' should be resolved using resolve_pattern_dependency"
));
}
if dep.is_local() {
let source = dep.get_source();
let resource_type = self.get_resource_type_with_source(name, source);
let filename = if let Some(custom_filename) = dep.get_filename() {
custom_filename.to_string()
} else {
let dep_path = Path::new(dep.get_path());
let relative_path = extract_relative_path(dep_path, &resource_type);
if relative_path.as_os_str().is_empty() || relative_path == dep_path {
let extension = match resource_type {
crate::core::ResourceType::Hook | crate::core::ResourceType::McpServer => {
"json"
}
crate::core::ResourceType::Script => {
dep_path.extension().and_then(|e| e.to_str()).unwrap_or("sh")
}
_ => "md",
};
format!("{name}.{extension}")
} else {
relative_path.to_string_lossy().to_string()
}
};
let artifact_type = match dep {
crate::manifest::ResourceDependency::Detailed(d) => &d.tool,
_ => "claude-code",
};
let installed_at = if let Some(custom_target) = dep.get_target() {
if let Some(artifact_path) =
self.manifest.get_artifact_resource_path(artifact_type, resource_type)
{
let base_target = artifact_path.display().to_string();
format!("{}/{}", base_target, custom_target.trim_start_matches('/'))
.replace("//", "/")
+ "/"
+ &filename
} else {
#[allow(deprecated)]
let base_target = match resource_type {
crate::core::ResourceType::Agent => &self.manifest.target.agents,
crate::core::ResourceType::Snippet => &self.manifest.target.snippets,
crate::core::ResourceType::Command => &self.manifest.target.commands,
crate::core::ResourceType::Script => &self.manifest.target.scripts,
crate::core::ResourceType::Hook => &self.manifest.target.hooks,
crate::core::ResourceType::McpServer => &self.manifest.target.mcp_servers,
};
format!("{}/{}", base_target, custom_target.trim_start_matches('/'))
.replace("//", "/")
+ "/"
+ &filename
}
} else {
if let Some(artifact_path) =
self.manifest.get_artifact_resource_path(artifact_type, resource_type)
{
format!("{}/{}", artifact_path.display(), filename)
} else {
#[allow(deprecated)]
let target_dir = match resource_type {
crate::core::ResourceType::Agent => &self.manifest.target.agents,
crate::core::ResourceType::Snippet => &self.manifest.target.snippets,
crate::core::ResourceType::Command => &self.manifest.target.commands,
crate::core::ResourceType::Script => &self.manifest.target.scripts,
crate::core::ResourceType::Hook => &self.manifest.target.hooks,
crate::core::ResourceType::McpServer => &self.manifest.target.mcp_servers,
};
format!("{target_dir}/{filename}")
}
}
.replace('\\', "/");
let unique_name = name.to_string();
let installed_at = match resource_type {
crate::core::ResourceType::Hook => ".claude/settings.local.json".to_string(),
crate::core::ResourceType::McpServer => {
match dep {
crate::manifest::ResourceDependency::Detailed(d)
if d.tool == "opencode" =>
{
".opencode/opencode.json".to_string()
}
_ => ".mcp.json".to_string(), }
}
_ => installed_at,
};
Ok(LockedResource {
name: unique_name,
source: None,
url: None,
path: dep.get_path().to_string(),
version: None,
resolved_commit: None,
checksum: String::new(),
installed_at,
dependencies: self.get_dependencies_for(name, None),
resource_type,
tool: match dep {
crate::manifest::ResourceDependency::Detailed(d) => d.tool.clone(),
_ => "claude-code".to_string(),
},
})
} else {
let source_name = dep.get_source().ok_or_else(|| AgpmError::ConfigError {
message: format!("Dependency '{name}' has no source specified"),
})?;
let source_url = self.source_manager.get_source_url(source_name).ok_or_else(|| {
AgpmError::SourceNotFound {
name: source_name.to_string(),
}
})?;
let version_key = dep
.get_version()
.map_or_else(|| "HEAD".to_string(), std::string::ToString::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 '{name}' from source '{source_name}' @ '{version_key}'"
));
}
};
let source = dep.get_source();
let resource_type = self.get_resource_type_with_source(name, source);
let filename = if let Some(custom_filename) = dep.get_filename() {
custom_filename.to_string()
} else {
let dep_path = Path::new(dep.get_path());
let relative_path = extract_relative_path(dep_path, &resource_type);
if relative_path.as_os_str().is_empty() || relative_path == dep_path {
let extension = match resource_type {
crate::core::ResourceType::Hook | crate::core::ResourceType::McpServer => {
"json"
}
crate::core::ResourceType::Script => {
dep_path.extension().and_then(|e| e.to_str()).unwrap_or("sh")
}
_ => "md",
};
format!("{name}.{extension}")
} else {
relative_path.to_string_lossy().to_string()
}
};
let artifact_type = match dep {
crate::manifest::ResourceDependency::Detailed(d) => &d.tool,
_ => "claude-code",
};
let installed_at = if let Some(custom_target) = dep.get_target() {
if let Some(artifact_path) =
self.manifest.get_artifact_resource_path(artifact_type, resource_type)
{
let base_target = artifact_path.display().to_string();
format!("{}/{}", base_target, custom_target.trim_start_matches('/'))
.replace("//", "/")
+ "/"
+ &filename
} else {
#[allow(deprecated)]
let base_target = match resource_type {
crate::core::ResourceType::Agent => &self.manifest.target.agents,
crate::core::ResourceType::Snippet => &self.manifest.target.snippets,
crate::core::ResourceType::Command => &self.manifest.target.commands,
crate::core::ResourceType::Script => &self.manifest.target.scripts,
crate::core::ResourceType::Hook => &self.manifest.target.hooks,
crate::core::ResourceType::McpServer => &self.manifest.target.mcp_servers,
};
format!("{}/{}", base_target, custom_target.trim_start_matches('/'))
.replace("//", "/")
+ "/"
+ &filename
}
} else {
if let Some(artifact_path) =
self.manifest.get_artifact_resource_path(artifact_type, resource_type)
{
format!("{}/{}", artifact_path.display(), filename)
} else {
#[allow(deprecated)]
let target_dir = match resource_type {
crate::core::ResourceType::Agent => &self.manifest.target.agents,
crate::core::ResourceType::Snippet => &self.manifest.target.snippets,
crate::core::ResourceType::Command => &self.manifest.target.commands,
crate::core::ResourceType::Script => &self.manifest.target.scripts,
crate::core::ResourceType::Hook => &self.manifest.target.hooks,
crate::core::ResourceType::McpServer => &self.manifest.target.mcp_servers,
};
format!("{target_dir}/{filename}")
}
}
.replace('\\', "/");
let unique_name = name.to_string();
let artifact_type = match dep {
crate::manifest::ResourceDependency::Detailed(d) => d.tool.clone(),
_ => "claude-code".to_string(),
};
let installed_at = match resource_type {
crate::core::ResourceType::Hook => ".claude/settings.local.json".to_string(),
crate::core::ResourceType::McpServer => {
match dep {
crate::manifest::ResourceDependency::Detailed(d)
if d.tool == "opencode" =>
{
".opencode/opencode.json".to_string()
}
_ => ".mcp.json".to_string(), }
}
_ => installed_at,
};
Ok(LockedResource {
name: unique_name,
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,
dependencies: self.get_dependencies_for(name, Some(source_name)),
resource_type,
tool: artifact_type,
})
}
}
fn get_dependencies_for(&self, name: &str, source: Option<&str>) -> Vec<String> {
let resource_type = self.get_resource_type_with_source(name, source);
let key = (resource_type, name.to_string(), source.map(std::string::ToString::to_string));
self.dependency_map.get(&key).cloned().unwrap_or_default()
}
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 relative_path = extract_relative_path(&matched_path, &resource_type);
#[allow(deprecated)]
let target_dir = if let Some(custom_target) = dep.get_target() {
let base_target = match resource_type {
crate::core::ResourceType::Agent => &self.manifest.target.agents,
crate::core::ResourceType::Snippet => &self.manifest.target.snippets,
crate::core::ResourceType::Command => &self.manifest.target.commands,
crate::core::ResourceType::Script => &self.manifest.target.scripts,
crate::core::ResourceType::Hook => &self.manifest.target.hooks,
crate::core::ResourceType::McpServer => &self.manifest.target.mcp_servers,
};
format!("{}/{}", base_target, custom_target.trim_start_matches('/'))
.replace("//", "/")
} else {
match resource_type {
crate::core::ResourceType::Agent => self.manifest.target.agents.clone(),
crate::core::ResourceType::Snippet => self.manifest.target.snippets.clone(),
crate::core::ResourceType::Command => self.manifest.target.commands.clone(),
crate::core::ResourceType::Script => self.manifest.target.scripts.clone(),
crate::core::ResourceType::Hook => self.manifest.target.hooks.clone(),
crate::core::ResourceType::McpServer => {
self.manifest.target.mcp_servers.clone()
}
}
};
let filename =
if relative_path.as_os_str().is_empty() || relative_path == matched_path {
let extension =
matched_path.extension().and_then(|e| e.to_str()).unwrap_or("md");
format!("{resource_name}.{extension}")
} else {
relative_path.to_string_lossy().to_string()
};
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())
};
let resource_type = self.get_resource_type(name);
let installed_at = match resource_type {
crate::core::ResourceType::Hook => ".claude/settings.local.json".to_string(),
crate::core::ResourceType::McpServer => {
match dep {
crate::manifest::ResourceDependency::Detailed(d)
if d.tool == "opencode" =>
{
".opencode/opencode.json".to_string()
}
_ => ".mcp.json".to_string(), }
}
_ => installed_at,
};
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,
dependencies: self.get_dependencies_for(&resource_name, None),
resource_type,
tool: match dep {
crate::manifest::ResourceDependency::Detailed(d) => d.tool.clone(),
_ => "claude-code".to_string(),
},
});
}
Ok(resources)
} else {
let source_name = dep.get_source().ok_or_else(|| AgpmError::ConfigError {
message: format!("Pattern dependency '{name}' has no source specified"),
})?;
let source_url = self.source_manager.get_source_url(source_name).ok_or_else(|| {
AgpmError::SourceNotFound {
name: source_name.to_string(),
}
})?;
let version_key = dep
.get_version()
.map_or_else(|| "HEAD".to_string(), std::string::ToString::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 '{source_name}' @ '{version_key}'. Stage 1 preparation should have populated this entry."
)
})?;
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 relative_path = extract_relative_path(&matched_path, &resource_type);
#[allow(deprecated)]
let target_dir = if let Some(custom_target) = dep.get_target() {
let base_target = match resource_type {
crate::core::ResourceType::Agent => &self.manifest.target.agents,
crate::core::ResourceType::Snippet => &self.manifest.target.snippets,
crate::core::ResourceType::Command => &self.manifest.target.commands,
crate::core::ResourceType::Script => &self.manifest.target.scripts,
crate::core::ResourceType::Hook => &self.manifest.target.hooks,
crate::core::ResourceType::McpServer => &self.manifest.target.mcp_servers,
};
format!("{}/{}", base_target, custom_target.trim_start_matches('/'))
.replace("//", "/")
} else {
match resource_type {
crate::core::ResourceType::Agent => self.manifest.target.agents.clone(),
crate::core::ResourceType::Snippet => self.manifest.target.snippets.clone(),
crate::core::ResourceType::Command => self.manifest.target.commands.clone(),
crate::core::ResourceType::Script => self.manifest.target.scripts.clone(),
crate::core::ResourceType::Hook => self.manifest.target.hooks.clone(),
crate::core::ResourceType::McpServer => {
self.manifest.target.mcp_servers.clone()
}
}
};
let filename =
if relative_path.as_os_str().is_empty() || relative_path == matched_path {
let extension =
matched_path.extension().and_then(|e| e.to_str()).unwrap_or("md");
format!("{resource_name}.{extension}")
} else {
relative_path.to_string_lossy().to_string()
};
let installed_at = format!("{target_dir}/{filename}");
let resource_type = self.get_resource_type(name);
let installed_at = match resource_type {
crate::core::ResourceType::Hook => ".claude/settings.local.json".to_string(),
crate::core::ResourceType::McpServer => {
match dep {
crate::manifest::ResourceDependency::Detailed(d)
if d.tool == "opencode" =>
{
".opencode/opencode.json".to_string()
}
_ => ".mcp.json".to_string(), }
}
_ => installed_at,
};
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,
dependencies: self.get_dependencies_for(&resource_name, Some(source_name)),
resource_type,
tool: match dep {
crate::manifest::ResourceDependency::Detailed(d) => d.tool.clone(),
_ => "claude-code".to_string(),
},
});
}
Ok(resources)
}
}
fn get_resource_type(&self, name: &str) -> crate::core::ResourceType {
self.get_resource_type_with_source(name, None)
}
fn get_resource_type_with_source(
&self,
name: &str,
source: Option<&str>,
) -> crate::core::ResourceType {
if self.manifest.agents.contains_key(name) {
crate::core::ResourceType::Agent
} else if self.manifest.snippets.contains_key(name) {
crate::core::ResourceType::Snippet
} else if self.manifest.commands.contains_key(name) {
crate::core::ResourceType::Command
} else if self.manifest.scripts.contains_key(name) {
crate::core::ResourceType::Script
} else if self.manifest.hooks.contains_key(name) {
crate::core::ResourceType::Hook
} else if self.manifest.mcp_servers.contains_key(name) {
crate::core::ResourceType::McpServer
} else {
let type_key = (name.to_string(), source.map(std::string::ToString::to_string));
if let Some(&resource_type) = self.transitive_types.get(&type_key) {
return resource_type;
}
for (resource_type, dep_name, _dep_source) in self.dependency_map.keys() {
if dep_name == name {
return *resource_type;
}
}
crate::core::ResourceType::Snippet }
}
fn resolve_version_conflict(
&self,
resource_name: &str,
existing: &ResourceDependency,
new_dep: &ResourceDependency,
requester: &str,
) -> Result<ResourceDependency> {
let existing_version = existing.get_version();
let new_version = new_dep.get_version();
if existing_version == new_version {
return Ok(existing.clone());
}
let is_existing_range = existing_version.is_some_and(|v| {
v.starts_with('^') || v.starts_with('~') || v.starts_with('>') || v.starts_with('<')
});
let is_new_range = new_version.is_some_and(|v| {
v.starts_with('^') || v.starts_with('~') || v.starts_with('>') || v.starts_with('<')
});
if is_existing_range || is_new_range {
return Err(AgpmError::Other {
message: format!(
"Version conflict for '{}': cannot resolve semver ranges automatically. \
Existing: {:?}, Required by '{}': {:?}. \
This should have been caught by conflict detection.",
resource_name,
existing_version.unwrap_or("HEAD"),
requester,
new_version.unwrap_or("HEAD")
),
}
.into());
}
tracing::warn!(
"Version conflict for '{}': existing version {:?} vs {:?} required by '{}'",
resource_name,
existing_version.unwrap_or("HEAD"),
new_version.unwrap_or("HEAD"),
requester
);
match (existing_version, new_version) {
(None, Some(_)) => {
Ok(new_dep.clone())
}
(Some(_), None) => {
Ok(existing.clone())
}
(Some(v1), Some(v2)) => {
use semver::Version;
let v1_semver = Version::parse(v1.trim_start_matches('v')).ok();
let v2_semver = Version::parse(v2.trim_start_matches('v')).ok();
match (v1_semver, v2_semver) {
(Some(sv1), Some(sv2)) => {
if sv1 >= sv2 {
tracing::info!(
"Resolving conflict: using version {} for {} (semver: {} >= {})",
v1,
resource_name,
sv1,
sv2
);
Ok(existing.clone())
} else {
tracing::info!(
"Resolving conflict: using version {} for {} (semver: {} < {})",
v2,
resource_name,
sv1,
sv2
);
Ok(new_dep.clone())
}
}
(Some(_), None) => {
tracing::info!(
"Resolving conflict: preferring semver version {} over git ref {} for {}",
v1,
v2,
resource_name
);
Ok(existing.clone())
}
(None, Some(_)) => {
tracing::info!(
"Resolving conflict: preferring semver version {} over git ref {} for {}",
v2,
v1,
resource_name
);
Ok(new_dep.clone())
}
(None, None) => {
if v1 <= v2 {
tracing::info!(
"Resolving conflict: using git ref {} for {} (alphabetically first)",
v1,
resource_name
);
Ok(existing.clone())
} else {
tracing::info!(
"Resolving conflict: using git ref {} for {} (alphabetically first)",
v2,
resource_name
);
Ok(new_dep.clone())
}
}
}
}
(None, None) => {
Ok(existing.clone())
}
}
}
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 base_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(&base_deps).await?;
let deps = self.resolve_transitive_dependencies(&base_deps, true).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 {
crate::core::ResourceType::Agent => {
if let Some(existing) = lockfile
.agents
.iter_mut()
.find(|e| e.name == entry.name && e.source == entry.source)
{
*existing = entry;
} else {
lockfile.agents.push(entry);
}
}
crate::core::ResourceType::Snippet => {
if let Some(existing) = lockfile
.snippets
.iter_mut()
.find(|e| e.name == entry.name && e.source == entry.source)
{
*existing = entry;
} else {
lockfile.snippets.push(entry);
}
}
crate::core::ResourceType::Command => {
if let Some(existing) = lockfile
.commands
.iter_mut()
.find(|e| e.name == entry.name && e.source == entry.source)
{
*existing = entry;
} else {
lockfile.commands.push(entry);
}
}
crate::core::ResourceType::Script => {
if let Some(existing) = lockfile
.scripts
.iter_mut()
.find(|e| e.name == entry.name && e.source == entry.source)
{
*existing = entry;
} else {
lockfile.scripts.push(entry);
}
}
crate::core::ResourceType::Hook => {
if let Some(existing) = lockfile
.hooks
.iter_mut()
.find(|e| e.name == entry.name && e.source == entry.source)
{
*existing = entry;
} else {
lockfile.hooks.push(entry);
}
}
crate::core::ResourceType::McpServer => {
if let Some(existing) = lockfile
.mcp_servers
.iter_mut()
.find(|e| e.name == entry.name && e.source == entry.source)
{
*existing = entry;
} else {
lockfile.mcp_servers.push(entry);
}
}
}
}
} else {
let entry = self.resolve_dependency(&name, &dep).await?;
self.add_or_update_lockfile_entry(&mut lockfile, &name, entry);
}
}
self.add_version_to_dependencies(&mut lockfile)?;
self.detect_target_conflicts(&lockfile)?;
Ok(lockfile)
}
fn add_to_conflict_detector(
&mut self,
_name: &str,
dep: &ResourceDependency,
required_by: &str,
) {
if dep.is_local() {
return;
}
let source = dep.get_source().unwrap_or("unknown");
let path = dep.get_path();
let resource_id = format!("{source}:{path}");
if let Some(version) = dep.get_version() {
self.conflict_detector.add_requirement(&resource_id, required_by, version);
} else {
self.conflict_detector.add_requirement(&resource_id, required_by, "HEAD");
}
}
fn add_version_to_dependencies(&self, lockfile: &mut LockFile) -> Result<()> {
let mut lookup_map: HashMap<(crate::core::ResourceType, String, Option<String>), String> =
HashMap::new();
let normalize_path = |path: &str| -> String { path.trim_start_matches("./").to_string() };
let extract_filename = |path: &str| -> Option<String> {
path.split('/').next_back().map(std::string::ToString::to_string)
};
for entry in &lockfile.agents {
let normalized_path = normalize_path(&entry.path);
lookup_map.insert(
(crate::core::ResourceType::Agent, normalized_path.clone(), entry.source.clone()),
entry.name.clone(),
);
if let Some(filename) = extract_filename(&entry.path) {
lookup_map.insert(
(crate::core::ResourceType::Agent, filename, entry.source.clone()),
entry.name.clone(),
);
}
}
for entry in &lockfile.snippets {
let normalized_path = normalize_path(&entry.path);
lookup_map.insert(
(crate::core::ResourceType::Snippet, normalized_path.clone(), entry.source.clone()),
entry.name.clone(),
);
if let Some(filename) = extract_filename(&entry.path) {
lookup_map.insert(
(crate::core::ResourceType::Snippet, filename, entry.source.clone()),
entry.name.clone(),
);
}
}
for entry in &lockfile.commands {
let normalized_path = normalize_path(&entry.path);
lookup_map.insert(
(crate::core::ResourceType::Command, normalized_path.clone(), entry.source.clone()),
entry.name.clone(),
);
if let Some(filename) = extract_filename(&entry.path) {
lookup_map.insert(
(crate::core::ResourceType::Command, filename, entry.source.clone()),
entry.name.clone(),
);
}
}
for entry in &lockfile.scripts {
let normalized_path = normalize_path(&entry.path);
lookup_map.insert(
(crate::core::ResourceType::Script, normalized_path.clone(), entry.source.clone()),
entry.name.clone(),
);
if let Some(filename) = extract_filename(&entry.path) {
lookup_map.insert(
(crate::core::ResourceType::Script, filename, entry.source.clone()),
entry.name.clone(),
);
}
}
for entry in &lockfile.hooks {
let normalized_path = normalize_path(&entry.path);
lookup_map.insert(
(crate::core::ResourceType::Hook, normalized_path.clone(), entry.source.clone()),
entry.name.clone(),
);
if let Some(filename) = extract_filename(&entry.path) {
lookup_map.insert(
(crate::core::ResourceType::Hook, filename, entry.source.clone()),
entry.name.clone(),
);
}
}
for entry in &lockfile.mcp_servers {
let normalized_path = normalize_path(&entry.path);
lookup_map.insert(
(
crate::core::ResourceType::McpServer,
normalized_path.clone(),
entry.source.clone(),
),
entry.name.clone(),
);
if let Some(filename) = extract_filename(&entry.path) {
lookup_map.insert(
(crate::core::ResourceType::McpServer, filename, entry.source.clone()),
entry.name.clone(),
);
}
}
let mut resource_info_map: HashMap<ResourceKey, ResourceInfo> = HashMap::new();
for entry in &lockfile.agents {
resource_info_map.insert(
(crate::core::ResourceType::Agent, entry.name.clone(), entry.source.clone()),
(entry.source.clone(), entry.version.clone()),
);
}
for entry in &lockfile.snippets {
resource_info_map.insert(
(crate::core::ResourceType::Snippet, entry.name.clone(), entry.source.clone()),
(entry.source.clone(), entry.version.clone()),
);
}
for entry in &lockfile.commands {
resource_info_map.insert(
(crate::core::ResourceType::Command, entry.name.clone(), entry.source.clone()),
(entry.source.clone(), entry.version.clone()),
);
}
for entry in &lockfile.scripts {
resource_info_map.insert(
(crate::core::ResourceType::Script, entry.name.clone(), entry.source.clone()),
(entry.source.clone(), entry.version.clone()),
);
}
for entry in &lockfile.hooks {
resource_info_map.insert(
(crate::core::ResourceType::Hook, entry.name.clone(), entry.source.clone()),
(entry.source.clone(), entry.version.clone()),
);
}
for entry in &lockfile.mcp_servers {
resource_info_map.insert(
(crate::core::ResourceType::McpServer, entry.name.clone(), entry.source.clone()),
(entry.source.clone(), entry.version.clone()),
);
}
let update_deps = |entries: &mut Vec<LockedResource>| {
for entry in entries {
let parent_source = entry.source.clone();
let updated_deps: Vec<String> =
entry
.dependencies
.iter()
.map(|dep| {
if let Some((_resource_type_str, dep_path)) = dep.split_once('/') {
if let Ok(resource_type) =
_resource_type_str.parse::<crate::core::ResourceType>()
{
let dep_filename = normalize_path(dep_path);
if let Some(dep_name) = lookup_map.get(&(
resource_type,
dep_filename.clone(),
parent_source.clone(),
)) {
return format!("{resource_type}/{dep_name}");
}
let dep_filename_with_ext = format!("{dep_filename}.md");
if let Some(dep_name) = lookup_map.get(&(
resource_type,
dep_filename_with_ext.clone(),
parent_source.clone(),
)) {
return format!("{resource_type}/{dep_name}");
}
for ((rt, filename, src), name) in &lookup_map {
if *rt == resource_type
&& (filename == &dep_filename
|| filename == &dep_filename_with_ext)
{
if let Some((source, version)) = resource_info_map
.get(&(resource_type, name.clone(), src.clone()))
{
let mut dep_ref = String::new();
if let Some(src) = source {
dep_ref.push_str(src);
dep_ref.push(':');
}
dep_ref.push_str(&resource_type.to_string());
dep_ref.push('/');
dep_ref.push_str(name);
if let Some(ver) = version {
dep_ref.push(':');
dep_ref.push_str(ver);
}
return dep_ref;
}
}
}
}
}
dep.clone()
})
.collect();
entry.dependencies = updated_deps;
}
};
update_deps(&mut lockfile.agents);
update_deps(&mut lockfile.snippets);
update_deps(&mut lockfile.commands);
update_deps(&mut lockfile.scripts);
update_deps(&mut lockfile.hooks);
update_deps(&mut lockfile.mcp_servers);
Ok(())
}
pub fn verify(&mut self) -> Result<()> {
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(|| AgpmError::ConfigError {
message: format!("Dependency '{name}' has no source specified"),
})?;
if !self.manifest.sources.contains_key(source_name) {
anyhow::bail!(
"Dependency '{name}' references undefined source: '{source_name}'"
);
}
}
}
Ok(())
}
}
pub fn extract_relative_path(path: &Path, resource_type: &crate::core::ResourceType) -> PathBuf {
let expected_prefix = match resource_type {
crate::core::ResourceType::Agent => "agents",
crate::core::ResourceType::Snippet => "snippets",
crate::core::ResourceType::Command => "commands",
crate::core::ResourceType::Script => "scripts",
crate::core::ResourceType::Hook => "hooks",
crate::core::ResourceType::McpServer => "mcp-servers",
};
let components: Vec<_> = path.components().collect();
if let Some(first) = components.first()
&& let std::path::Component::Normal(name) = first
&& name.to_str() == Some(expected_prefix)
{
let remaining: PathBuf = components[1..].iter().collect();
return remaining;
}
path.to_path_buf()
}
#[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]
#[ignore = "Redundancy checking removed - using automatic conflict resolution"]
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(Box::new(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,
dependencies: None,
tool: "claude-code".to_string(),
})),
true,
);
manifest.add_dependency(
"agent2".to_string(),
ResourceDependency::Detailed(Box::new(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,
dependencies: None,
tool: "claude-code".to_string(),
})),
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);
}
#[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(Box::new(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,
dependencies: None,
tool: "claude-code".to_string(),
})),
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(Box::new(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,
dependencies: None,
tool: "claude-code".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("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"), crate::core::ResourceType::Agent);
assert_eq!(resolver.get_resource_type("snippet1"), crate::core::ResourceType::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 = format!("file://{}", source_dir.display());
manifest.add_source("test".to_string(), source_url);
manifest.add_dependency(
"remote-agent".to_string(),
ResourceDependency::Detailed(Box::new(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,
dependencies: None,
tool: "claude-code".to_string(),
})),
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(Box::new(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,
dependencies: None,
tool: "claude-code".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_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 = format!("file://{}", source_dir.display());
manifest.add_source("test".to_string(), source_url);
manifest.add_dependency(
"git-agent".to_string(),
ResourceDependency::Detailed(Box::new(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,
dependencies: None,
tool: "claude-code".to_string(),
})),
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]
#[ignore = "Redundancy checking removed - using automatic conflict resolution"]
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(Box::new(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,
dependencies: None,
tool: "claude-code".to_string(),
})),
true,
);
manifest.add_dependency(
"agent2".to_string(),
ResourceDependency::Detailed(Box::new(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,
dependencies: None,
tool: "claude-code".to_string(),
})),
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);
}
#[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(Box::new(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,
dependencies: None,
tool: "claude-code".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, "custom-agent");
let normalized_path = agent.installed_at.replace('\\', "/");
assert!(normalized_path.contains(".claude/agents/integrations/custom"));
assert_eq!(normalized_path, ".claude/agents/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(Box::new(crate::manifest::DetailedDependency {
source: None,
path: "../test.md".to_string(),
version: None,
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".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, "standard-agent");
let normalized_path = agent.installed_at.replace('\\', "/");
assert_eq!(normalized_path, ".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(Box::new(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()),
dependencies: None,
tool: "claude-code".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");
let normalized_path = agent.installed_at.replace('\\', "/");
assert_eq!(normalized_path, ".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(Box::new(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()),
dependencies: None,
tool: "claude-code".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");
let normalized_path = agent.installed_at.replace('\\', "/");
assert_eq!(normalized_path, ".claude/agents/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(Box::new(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()),
dependencies: None,
tool: "claude-code".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");
let normalized_path = script.installed_at.replace('\\', "/");
assert_eq!(normalized_path, ".claude/agpm/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 = format!("file://{}", source_dir.display());
manifest.add_source("test".to_string(), source_url);
manifest.add_dependency(
"python-tools".to_string(),
ResourceDependency::Detailed(Box::new(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,
dependencies: None,
tool: "claude-code".to_string(),
})),
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(Box::new(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,
dependencies: None,
tool: "claude-code".to_string(),
})),
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/agents/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 = format!("file://{}", source_dir.display());
manifest.add_source("test".to_string(), source_url);
manifest.add_dependency(
"agent1".to_string(),
ResourceDependency::Detailed(Box::new(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,
dependencies: None,
tool: "claude-code".to_string(),
})),
true,
);
manifest.add_dependency(
"agent2".to_string(),
ResourceDependency::Detailed(Box::new(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,
dependencies: None,
tool: "claude-code".to_string(),
})),
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(), format!("file://{}", source_dir.display()));
updated_manifest.add_dependency(
"agent1".to_string(),
ResourceDependency::Detailed(Box::new(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,
dependencies: None,
tool: "claude-code".to_string(),
})),
true,
);
updated_manifest.add_dependency(
"agent2".to_string(),
ResourceDependency::Detailed(Box::new(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,
dependencies: None,
tool: "claude-code".to_string(),
})),
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" && a.version.as_deref() == Some("v2.0.0"))
.unwrap();
assert_eq!(agent1.version.as_ref().unwrap(), "v2.0.0");
let agent2 = updated_lockfile
.agents
.iter()
.find(|a| a.name == "agent2" && a.version.as_deref() == Some("v1.0.0"))
.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_eq!(
hook.installed_at, ".claude/settings.local.json",
"Hooks should reference the config file where they're configured"
);
}
}
#[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_eq!(
server.installed_at, ".mcp.json",
"MCP servers should reference the config file where they're configured"
);
}
}
#[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 {
let normalized_path = command.installed_at.replace('\\', "/");
assert!(normalized_path.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 = format!("file://{}", source_dir.display());
manifest.add_source("test".to_string(), source_url);
manifest.add_dependency(
"constrained-dep".to_string(),
ResourceDependency::Detailed(Box::new(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,
dependencies: None,
tool: "claude-code".to_string(),
})),
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(Box::new(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,
dependencies: None,
tool: "claude-code".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.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 = format!("file://{}", source_dir.display());
manifest.add_source("test".to_string(), source_url);
manifest.add_dependency(
"branch-dep".to_string(),
ResourceDependency::Detailed(Box::new(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,
dependencies: None,
tool: "claude-code".to_string(),
})),
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 = format!("file://{}", source_dir.display());
manifest.add_source("test".to_string(), source_url);
manifest.add_dependency(
"commit-dep".to_string(),
ResourceDependency::Detailed(Box::new(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,
dependencies: None,
tool: "claude-code".to_string(),
})),
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]
#[ignore = "Redundancy checking removed - using automatic conflict resolution"]
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(Box::new(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,
dependencies: None,
tool: "claude-code".to_string(),
})),
true,
);
manifest.add_dependency(
"helper-v2".to_string(),
ResourceDependency::Detailed(Box::new(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,
dependencies: None,
tool: "claude-code".to_string(),
})),
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);
}
#[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);
}
#[test]
fn test_resolve_version_conflict_rejects_semver_ranges() {
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);
let existing = ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("test".to_string()),
path: "agents/test.md".to_string(),
version: Some("^1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
}));
let new_dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("test".to_string()),
path: "agents/test.md".to_string(),
version: Some("^1.5.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
}));
let result = resolver.resolve_version_conflict("test-agent", &existing, &new_dep, "app1");
assert!(result.is_err(), "Should reject caret range");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("cannot resolve semver ranges"),
"Error should mention semver ranges: {}",
err_msg
);
let existing_tilde = ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("test".to_string()),
path: "agents/test.md".to_string(),
version: Some("~1.2.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
}));
let result2 =
resolver.resolve_version_conflict("test-agent", &existing_tilde, &new_dep, "app2");
assert!(result2.is_err(), "Should reject tilde range");
let existing_gte = ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("test".to_string()),
path: "agents/test.md".to_string(),
version: Some(">=1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
}));
let result3 =
resolver.resolve_version_conflict("test-agent", &existing_gte, &new_dep, "app3");
assert!(result3.is_err(), "Should reject >= operator");
}
#[test]
fn test_resolve_version_conflict_semver_preference() {
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);
let existing_semver = ResourceDependency::Detailed(Box::new(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,
dependencies: None,
tool: "claude-code".to_string(),
}));
let new_branch = ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("test".to_string()),
path: "agents/test.md".to_string(),
version: None,
branch: Some("main".to_string()),
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
}));
let result =
resolver.resolve_version_conflict("test-agent", &existing_semver, &new_branch, "app1");
assert!(result.is_ok(), "Should succeed with semver preference");
let resolved = result.unwrap();
assert_eq!(
resolved.get_version(),
Some("v1.0.0"),
"Should prefer semver version over branch"
);
let result2 =
resolver.resolve_version_conflict("test-agent", &new_branch, &existing_semver, "app2");
assert!(result2.is_ok(), "Should succeed with semver preference");
let resolved2 = result2.unwrap();
assert_eq!(
resolved2.get_version(),
Some("v1.0.0"),
"Should prefer semver version over branch (reversed order)"
);
}
#[test]
fn test_resolve_version_conflict_semver_comparison() {
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);
let existing_v1 = ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("test".to_string()),
path: "agents/test.md".to_string(),
version: Some("v1.5.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
}));
let new_v2 = ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("test".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,
dependencies: None,
tool: "claude-code".to_string(),
}));
let result = resolver.resolve_version_conflict("test-agent", &existing_v1, &new_v2, "app1");
assert!(result.is_ok(), "Should succeed with higher version");
let resolved = result.unwrap();
assert_eq!(resolved.get_version(), Some("v2.0.0"), "Should use higher semver version");
let result2 =
resolver.resolve_version_conflict("test-agent", &new_v2, &existing_v1, "app2");
assert!(result2.is_ok(), "Should succeed with higher version");
let resolved2 = result2.unwrap();
assert_eq!(
resolved2.get_version(),
Some("v2.0.0"),
"Should use higher semver version (reversed order)"
);
}
#[test]
fn test_resolve_version_conflict_git_refs() {
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);
let existing_main = ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("test".to_string()),
path: "agents/test.md".to_string(),
version: None,
branch: Some("main".to_string()),
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
}));
let new_develop = ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("test".to_string()),
path: "agents/test.md".to_string(),
version: None,
branch: Some("develop".to_string()),
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
}));
let result =
resolver.resolve_version_conflict("test-agent", &existing_main, &new_develop, "app1");
assert!(result.is_ok(), "Should succeed with alphabetical ordering");
let resolved = result.unwrap();
assert_eq!(
resolved.get_version(),
Some("develop"),
"Should use alphabetically first git ref"
);
}
#[test]
fn test_resolve_version_conflict_head_vs_specific() {
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);
let existing_head = ResourceDependency::Simple("agents/test.md".to_string());
let new_specific = ResourceDependency::Detailed(Box::new(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,
dependencies: None,
tool: "claude-code".to_string(),
}));
let result =
resolver.resolve_version_conflict("test-agent", &existing_head, &new_specific, "app1");
assert!(result.is_ok(), "Should succeed with specific version");
let resolved = result.unwrap();
assert_eq!(
resolved.get_version(),
Some("v1.0.0"),
"Should prefer specific version over HEAD"
);
}
}