pub mod dependency_graph;
pub mod version_resolution;
pub mod version_resolver;
use crate::cache::Cache;
use crate::core::{AgpmError, ResourceType};
use crate::git::GitRepo;
use crate::lockfile::{LockFile, LockedResource};
use crate::manifest::{Manifest, ResourceDependency};
use crate::metadata::MetadataExtractor;
use crate::source::SourceManager;
use crate::utils::{compute_relative_install_path, normalize_path, normalize_path_for_storage};
use crate::version::conflict::ConflictDetector;
use anyhow::{Context, Result};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::time::Duration;
use self::dependency_graph::{DependencyGraph, DependencyNode};
use self::version_resolver::VersionResolver;
type DependencyKey = (ResourceType, String, Option<String>, Option<String>);
pub fn extract_meaningful_path(path: &Path) -> String {
let components: Vec<_> = path.components().collect();
if path.is_absolute() {
let mut resolved = Vec::new();
for component in components.iter() {
match component {
std::path::Component::Normal(name) => {
resolved.push(name.to_str().unwrap_or(""));
}
std::path::Component::ParentDir => {
resolved.pop();
}
_ => {}
}
}
resolved.join("/")
} else if components.iter().any(|c| matches!(c, std::path::Component::ParentDir)) {
let start_idx = components
.iter()
.position(|c| matches!(c, std::path::Component::Normal(_)))
.unwrap_or(0);
components[start_idx..]
.iter()
.filter_map(|c| c.as_os_str().to_str())
.collect::<Vec<_>>()
.join("/")
} else {
path.to_str().unwrap_or("").replace('\\', "/") }
}
type ResourceKey = (crate::core::ResourceType, String, Option<String>);
type ResourceInfo = (Option<String>, Option<String>);
fn read_with_cache_retry_sync(path: &Path) -> Result<String> {
use std::io;
use std::thread;
let mut attempts = 0;
const MAX_ATTEMPTS: u32 = 10;
loop {
match std::fs::read_to_string(path) {
Ok(content) => return Ok(content),
Err(e) if e.kind() == io::ErrorKind::NotFound && attempts < MAX_ATTEMPTS => {
attempts += 1;
let delay_ms = std::cmp::min(10 * (1 << attempts), 500);
let delay = Duration::from_millis(delay_ms);
tracing::debug!(
"File not yet visible (attempt {}/{}): {}",
attempts,
MAX_ATTEMPTS,
path.display()
);
thread::sleep(delay);
}
Err(e) => {
return Err(anyhow::anyhow!("Failed to read file: {}", path.display()).context(e));
}
}
}
}
pub struct DependencyResolver {
manifest: Manifest,
pub source_manager: SourceManager,
cache: Cache,
prepared_versions: HashMap<String, PreparedSourceVersion>,
version_resolver: VersionResolver,
dependency_map: HashMap<DependencyKey, Vec<String>>,
conflict_detector: ConflictDetector,
pattern_alias_map: HashMap<(ResourceType, String), String>,
}
#[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 && e.tool == entry.tool)
{
*existing = entry;
} else {
resources.push(entry);
}
}
fn remove_stale_manifest_entries(&self, lockfile: &mut LockFile) {
use std::collections::HashSet;
let manifest_agents: HashSet<String> =
self.manifest.agents.keys().map(|k| k.to_string()).collect();
let manifest_snippets: HashSet<String> =
self.manifest.snippets.keys().map(|k| k.to_string()).collect();
let manifest_commands: HashSet<String> =
self.manifest.commands.keys().map(|k| k.to_string()).collect();
let manifest_scripts: HashSet<String> =
self.manifest.scripts.keys().map(|k| k.to_string()).collect();
let manifest_hooks: HashSet<String> =
self.manifest.hooks.keys().map(|k| k.to_string()).collect();
let manifest_mcp_servers: HashSet<String> =
self.manifest.mcp_servers.keys().map(|k| k.to_string()).collect();
let get_manifest_keys = |resource_type: crate::core::ResourceType| match resource_type {
crate::core::ResourceType::Agent => &manifest_agents,
crate::core::ResourceType::Snippet => &manifest_snippets,
crate::core::ResourceType::Command => &manifest_commands,
crate::core::ResourceType::Script => &manifest_scripts,
crate::core::ResourceType::Hook => &manifest_hooks,
crate::core::ResourceType::McpServer => &manifest_mcp_servers,
};
let mut entries_to_remove: HashSet<(String, Option<String>)> = HashSet::new();
let mut direct_entries: Vec<(String, Option<String>)> = Vec::new();
for resource_type in crate::core::ResourceType::all() {
let manifest_keys = get_manifest_keys(*resource_type);
let resources = lockfile.get_resources(*resource_type);
for entry in resources {
let is_stale = if let Some(ref alias) = entry.manifest_alias {
!manifest_keys.contains(alias)
} else {
!manifest_keys.contains(&entry.name)
};
if is_stale {
let key = (entry.name.clone(), entry.source.clone());
entries_to_remove.insert(key.clone());
direct_entries.push(key);
}
}
}
for (parent_name, parent_source) in direct_entries {
for resource_type in crate::core::ResourceType::all() {
if let Some(parent_entry) = lockfile
.get_resources(*resource_type)
.iter()
.find(|e| e.name == parent_name && e.source == parent_source)
{
Self::collect_transitive_children(
lockfile,
parent_entry,
&mut entries_to_remove,
);
}
}
}
let should_remove = |entry: &crate::lockfile::LockedResource| {
entries_to_remove.contains(&(entry.name.clone(), entry.source.clone()))
};
lockfile.agents.retain(|entry| !should_remove(entry));
lockfile.snippets.retain(|entry| !should_remove(entry));
lockfile.commands.retain(|entry| !should_remove(entry));
lockfile.scripts.retain(|entry| !should_remove(entry));
lockfile.hooks.retain(|entry| !should_remove(entry));
lockfile.mcp_servers.retain(|entry| !should_remove(entry));
}
fn remove_manifest_entries_for_update(
&self,
lockfile: &mut LockFile,
manifest_keys: &HashSet<String>,
) {
use std::collections::HashSet;
let mut entries_to_remove: HashSet<(String, Option<String>)> = HashSet::new();
let mut direct_entries: Vec<(String, Option<String>)> = Vec::new();
for resource_type in crate::core::ResourceType::all() {
let resources = lockfile.get_resources(*resource_type);
for entry in resources {
if manifest_keys.contains(&entry.name)
|| entry
.manifest_alias
.as_ref()
.is_some_and(|alias| manifest_keys.contains(alias))
{
let key = (entry.name.clone(), entry.source.clone());
entries_to_remove.insert(key.clone());
direct_entries.push(key);
}
}
}
for (parent_name, parent_source) in direct_entries {
for resource_type in crate::core::ResourceType::all() {
if let Some(parent_entry) = lockfile
.get_resources(*resource_type)
.iter()
.find(|e| e.name == parent_name && e.source == parent_source)
{
Self::collect_transitive_children(
lockfile,
parent_entry,
&mut entries_to_remove,
);
}
}
}
let should_remove = |entry: &crate::lockfile::LockedResource| {
entries_to_remove.contains(&(entry.name.clone(), entry.source.clone()))
};
lockfile.agents.retain(|entry| !should_remove(entry));
lockfile.snippets.retain(|entry| !should_remove(entry));
lockfile.commands.retain(|entry| !should_remove(entry));
lockfile.scripts.retain(|entry| !should_remove(entry));
lockfile.hooks.retain(|entry| !should_remove(entry));
lockfile.mcp_servers.retain(|entry| !should_remove(entry));
}
fn collect_transitive_children(
lockfile: &crate::lockfile::LockFile,
parent: &crate::lockfile::LockedResource,
entries_to_remove: &mut std::collections::HashSet<(String, Option<String>)>,
) {
for dep_ref in &parent.dependencies {
let (dep_source, dep_name) = if let Some(colon_pos) = dep_ref.find(':') {
let source_part = &dep_ref[..colon_pos];
let rest = &dep_ref[colon_pos + 1..];
let type_name_part = if let Some(ver_colon) = rest.rfind(':') {
&rest[..ver_colon]
} else {
rest
};
if let Some(slash_pos) = type_name_part.find('/') {
let name = &type_name_part[slash_pos + 1..];
(Some(source_part.to_string()), name.to_string())
} else {
continue; }
} else {
if let Some(slash_pos) = dep_ref.find('/') {
let name = &dep_ref[slash_pos + 1..];
(parent.source.clone(), name.to_string())
} else {
continue; }
};
for resource_type in crate::core::ResourceType::all() {
if let Some(dep_entry) = lockfile
.get_resources(*resource_type)
.iter()
.find(|e| e.name == dep_name && e.source == dep_source)
{
let key = (dep_entry.name.clone(), dep_entry.source.clone());
if !entries_to_remove.contains(&key) {
entries_to_remove.insert(key);
Self::collect_transitive_children(lockfile, dep_entry, entries_to_remove);
}
}
}
}
}
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 with different content.\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 this conflict:\n\
1. Use custom 'target' field to specify different installation paths:\n\
Example: target = \"custom/subdir/file.md\"\n\n\
2. Use custom 'filename' field to specify different filenames:\n\
Example: filename = \"utils-v2.md\"\n\n\
3. For transitive dependencies, add them as direct dependencies with custom target/filename\n\n\
4. Ensure pattern dependencies don't overlap with single-file dependencies\n\n\
Note: This often occurs when different dependencies have transitive dependencies\n\
with the same name but from different sources.",
);
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(),
conflict_detector: ConflictDetector::new(),
pattern_alias_map: HashMap::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(),
conflict_detector: ConflictDetector::new(),
pattern_alias_map: HashMap::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(),
conflict_detector: ConflictDetector::new(),
pattern_alias_map: HashMap::new(),
}
}
async fn resolve_transitive_dependencies(
&mut self,
base_deps: &[(String, ResourceDependency, crate::core::ResourceType)],
enable_transitive: bool,
) -> Result<Vec<(String, ResourceDependency, crate::core::ResourceType)>> {
self.dependency_map.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>, Option<String>),
ResourceDependency,
> = HashMap::new();
let mut processed: HashSet<(
crate::core::ResourceType,
String,
Option<String>,
Option<String>,
)> = HashSet::new();
let mut queue: Vec<(String, ResourceDependency, Option<crate::core::ResourceType>)> =
Vec::new();
for (name, dep, resource_type) in base_deps {
let source = dep.get_source().map(std::string::ToString::to_string);
let tool = dep.get_tool().map(std::string::ToString::to_string);
queue.push((name.clone(), dep.clone(), Some(*resource_type)));
all_deps.insert((*resource_type, name.clone(), source, tool), dep.clone());
}
while let Some((name, dep, resource_type)) = queue.pop() {
let source = dep.get_source().map(std::string::ToString::to_string);
let tool = dep.get_tool().map(std::string::ToString::to_string);
let resource_type =
resource_type.expect("resource_type should always be threaded through queue");
let key = (resource_type, name.clone(), source.clone(), tool.clone());
if let Some(current_dep) = all_deps.get(&key) {
if current_dep.get_version() != dep.get_version() {
continue;
}
}
if processed.contains(&key) {
continue;
}
processed.insert(key.clone());
if dep.is_pattern() {
match self.expand_pattern_to_concrete_deps(&name, &dep, resource_type).await {
Ok(concrete_deps) => {
for (concrete_name, concrete_dep) in concrete_deps {
self.pattern_alias_map
.insert((resource_type, concrete_name.clone()), name.clone());
let concrete_source =
concrete_dep.get_source().map(std::string::ToString::to_string);
let concrete_tool =
concrete_dep.get_tool().map(std::string::ToString::to_string);
let concrete_key = (
resource_type,
concrete_name.clone(),
concrete_source,
concrete_tool,
);
if let std::collections::hash_map::Entry::Vacant(e) =
all_deps.entry(concrete_key)
{
e.insert(concrete_dep.clone());
queue.push((concrete_name, concrete_dep, Some(resource_type)));
}
}
}
Err(e) => {
anyhow::bail!(
"Failed to expand pattern '{}' for transitive dependency extraction: {}",
dep.get_path(),
e
);
}
}
continue; }
let content = self.fetch_resource_content(&name, &dep).await.with_context(|| {
format!("Failed to fetch resource '{name}' for transitive dependency extraction")
})?;
let path = PathBuf::from(dep.get_path());
let metadata =
MetadataExtractor::extract(&path, &content, self.manifest.project.as_ref())?;
if let Some(deps_map) = metadata.dependencies {
tracing::debug!(
"Processing transitive deps for: {} (has source: {:?})",
name,
dep.get_source()
);
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 parent_file_path = self
.get_canonical_path_for_dependency(&dep)
.await
.with_context(|| {
format!(
"Failed to get parent path for transitive dependencies of '{}'",
name
)
})?;
let is_pattern = dep_spec.path.contains('*')
|| dep_spec.path.contains('?')
|| dep_spec.path.contains('[');
let trans_canonical = if is_pattern {
let parent_dir = parent_file_path.parent()
.ok_or_else(|| anyhow::anyhow!(
"Failed to resolve transitive dependency '{}' for '{}': parent file has no directory",
dep_spec.path, name
))?;
let resolved = parent_dir.join(&dep_spec.path);
let mut result = PathBuf::new();
for component in resolved.components() {
match component {
std::path::Component::RootDir => {
result.push(component);
} std::path::Component::ParentDir => {
result.pop();
}
std::path::Component::CurDir => {}
_ => {
result.push(component);
}
}
}
result
} else if dep_spec.path.starts_with("./")
|| dep_spec.path.starts_with("../")
{
crate::utils::resolve_file_relative_path(
&parent_file_path,
&dep_spec.path,
)
.with_context(|| {
format!(
"Failed to resolve transitive dependency '{}' for '{}'",
dep_spec.path, name
)
})?
} else {
let repo_root = if dep.get_source().is_some() {
parent_file_path.ancestors()
.find(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|s| s.contains('_'))
.unwrap_or(false)
})
.ok_or_else(|| anyhow::anyhow!(
"Failed to find worktree root for transitive dependency '{}'",
dep_spec.path
))?
} else {
parent_file_path.ancestors()
.nth(2) .ok_or_else(|| anyhow::anyhow!(
"Failed to find source root for transitive dependency '{}'",
dep_spec.path
))?
};
let full_path = repo_root.join(&dep_spec.path);
full_path.canonicalize().with_context(|| {
format!(
"Failed to resolve repo-relative transitive dependency '{}' for '{}': {} (repo root: {})",
dep_spec.path, name, full_path.display(), repo_root.display()
)
})?
};
use crate::manifest::DetailedDependency;
let trans_dep = if dep.get_source().is_none() {
let manifest_dir = self.manifest.manifest_dir.as_ref()
.ok_or_else(|| anyhow::anyhow!("Manifest directory not available for path-only transitive dep"))?;
let dep_path_str = match manifest_dir.canonicalize() {
Ok(canonical_manifest) => {
crate::utils::compute_relative_path(
&canonical_manifest,
&trans_canonical,
)
}
Err(e) => {
eprintln!(
"Warning: Could not canonicalize manifest directory {}: {}. Using non-canonical path for relative path computation.",
manifest_dir.display(),
e
);
crate::utils::compute_relative_path(
manifest_dir,
&trans_canonical,
)
}
};
let trans_tool = if let Some(explicit_tool) = &dep_spec.tool {
Some(explicit_tool.clone())
} else {
let parent_tool =
dep.get_tool().map(str::to_string).unwrap_or_else(|| {
self.manifest.get_default_tool(resource_type)
});
if self
.manifest
.is_resource_supported(&parent_tool, dep_resource_type)
{
Some(parent_tool)
} else {
Some(self.manifest.get_default_tool(dep_resource_type))
}
};
ResourceDependency::Detailed(Box::new(DetailedDependency {
source: None,
path: crate::utils::normalize_path_for_storage(dep_path_str),
version: None,
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: trans_tool,
flatten: None,
install: dep_spec.install.or(Some(true)),
}))
} else {
let source_name = dep.get_source().ok_or_else(|| {
anyhow::anyhow!("Expected source for Git-backed dependency")
})?;
let version = dep.get_version().unwrap_or("main").to_string();
let source_url =
self.source_manager.get_source_url(source_name).ok_or_else(
|| anyhow::anyhow!("Source '{source_name}' not found"),
)?;
let repo_relative = if crate::utils::is_local_path(&source_url) {
let source_path = PathBuf::from(&source_url).canonicalize()?;
trans_canonical.strip_prefix(&source_path)
.with_context(|| format!(
"Transitive dep resolved outside parent's source directory: {} not under {}",
trans_canonical.display(),
source_path.display()
))?
.to_path_buf()
} else {
let sha = self
.prepared_versions
.get(&Self::group_key(source_name, &version))
.ok_or_else(|| {
anyhow::anyhow!(
"Parent version not resolved for {}",
source_name
)
})?
.resolved_commit
.clone();
let worktree_path = self
.cache
.get_or_create_worktree_for_sha(
source_name,
&source_url,
&sha,
None,
)
.await?;
let canonical_worktree =
worktree_path.canonicalize().with_context(|| {
format!(
"Failed to canonicalize worktree path: {}",
worktree_path.display()
)
})?;
trans_canonical.strip_prefix(&canonical_worktree)
.with_context(|| format!(
"Transitive dep resolved outside parent's worktree: {} not under {}",
trans_canonical.display(),
canonical_worktree.display()
))?
.to_path_buf()
};
let trans_tool = if let Some(explicit_tool) = &dep_spec.tool {
Some(explicit_tool.clone())
} else {
let parent_tool =
dep.get_tool().map(str::to_string).unwrap_or_else(|| {
self.manifest.get_default_tool(resource_type)
});
if self
.manifest
.is_resource_supported(&parent_tool, dep_resource_type)
{
Some(parent_tool)
} else {
Some(self.manifest.get_default_tool(dep_resource_type))
}
};
ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some(source_name.to_string()),
path: crate::utils::normalize_path_for_storage(
repo_relative.to_string_lossy().to_string(),
),
version: dep_spec
.version
.clone()
.or_else(|| dep.get_version().map(|v| v.to_string())),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: trans_tool,
flatten: None,
install: dep_spec.install.or(Some(true)),
}))
};
let trans_name = dep_spec
.name
.clone()
.unwrap_or_else(|| self.generate_dependency_name(trans_dep.get_path()));
let trans_source =
trans_dep.get_source().map(std::string::ToString::to_string);
let trans_tool = trans_dep.get_tool().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(), tool.clone());
let dep_ref = format!("{dep_resource_type}/{trans_name}");
self.dependency_map.entry(from_key).or_default().push(dep_ref);
self.add_to_conflict_detector(&trans_name, &trans_dep, &name);
let trans_key = (
dep_resource_type,
trans_name.clone(),
trans_source.clone(),
trans_tool.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, )
.await?;
let needs_reprocess =
resolved_dep.get_version() != existing_dep.get_version();
all_deps.insert(trans_key.clone(), resolved_dep.clone());
if needs_reprocess {
processed.remove(&trans_key);
queue.push((
trans_name.clone(),
resolved_dep,
Some(dep_resource_type),
));
}
} else {
tracing::debug!(
"Adding transitive dep '{}' to all_deps and queue (parent: {})",
trans_name,
name
);
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();
tracing::debug!(
"Transitive resolution - topological order has {} nodes, all_deps has {} entries",
ordered_nodes.len(),
all_deps.len()
);
for node in ordered_nodes {
tracing::debug!(
"Processing ordered node: {}/{} (source: {:?})",
node.resource_type,
node.name,
node.source
);
for (key, dep) in &all_deps {
if key.0 == node.resource_type && key.1 == node.name && key.2 == node.source {
tracing::debug!(
" -> Found match in all_deps, adding to result with type {:?}",
node.resource_type
);
result.push((node.name.clone(), dep.clone(), node.resource_type));
added_keys.insert(key.clone());
break; }
}
}
for (key, dep) in all_deps {
if !added_keys.contains(&key) {
if dep.is_pattern() {
tracing::debug!(
"Skipping pattern dependency in final result: {}/{} (source: {:?})",
key.0,
key.1,
key.2
);
continue;
}
tracing::debug!(
"Adding non-graph dependency: {}/{} (source: {:?}) with type {:?}",
key.0,
key.1,
key.2,
key.0
);
result.push((key.1.clone(), dep.clone(), key.0));
}
}
tracing::debug!("Transitive resolution returning {} dependencies", result.len());
Ok(result)
}
async fn fetch_resource_content(
&mut self,
_name: &str,
dep: &ResourceDependency,
) -> Result<String> {
match dep {
ResourceDependency::Simple(path) => {
let manifest_dir = self.manifest.manifest_dir.as_ref().ok_or_else(|| {
anyhow::anyhow!("Manifest directory not available for Simple dependency")
})?;
let full_path =
crate::utils::resolve_path_relative_to_manifest(manifest_dir, 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?;
let resolved_sha = self
.version_resolver
.get_resolved_sha(source_name, &version)
.ok_or_else(|| {
anyhow::anyhow!(
"Failed to resolve version for {source_name} @ {version}"
)
})?;
self.prepared_versions.insert(
Self::group_key(source_name, &version),
PreparedSourceVersion {
worktree_path: PathBuf::new(), resolved_version: Some(version.clone()),
resolved_commit: resolved_sha.clone(),
},
);
resolved_sha
};
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);
read_with_cache_retry_sync(&file_path)
}
} else {
let manifest_dir = self.manifest.manifest_dir.as_ref().ok_or_else(|| {
anyhow::anyhow!(
"Manifest directory not available for local Detailed dependency"
)
})?;
let full_path = crate::utils::resolve_path_relative_to_manifest(
manifest_dir,
&detailed.path,
)?;
std::fs::read_to_string(&full_path).with_context(|| {
format!("Failed to read local file: {}", full_path.display())
})
}
}
}
}
async fn get_canonical_path_for_dependency(
&mut self,
dep: &ResourceDependency,
) -> Result<PathBuf> {
if dep.get_source().is_none() {
let manifest_dir = self
.manifest
.manifest_dir
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Manifest directory not available"))?;
crate::utils::resolve_path_relative_to_manifest(manifest_dir, dep.get_path())
} else {
let source_name = dep
.get_source()
.ok_or_else(|| anyhow::anyhow!("Cannot get worktree for path-only dependency"))?;
let version = dep.get_version().unwrap_or("main").to_string();
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(dep.get_path());
file_path.canonicalize().with_context(|| {
format!("Failed to canonicalize local source resource: {}", file_path.display())
})
} else {
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 full_path = worktree_path.join(dep.get_path());
full_path.canonicalize().with_context(|| {
format!("Failed to canonicalize Git resource: {}", full_path.display())
})
}
}
}
async fn expand_pattern_to_concrete_deps(
&self,
_name: &str,
dep: &ResourceDependency,
_resource_type: crate::core::ResourceType,
) -> Result<Vec<(String, ResourceDependency)>> {
use crate::manifest::DetailedDependency;
let pattern = dep.get_path();
if dep.is_local() {
let pattern_path = Path::new(pattern);
let (base_path, search_pattern) = if pattern_path.is_absolute() {
let components: Vec<_> = pattern_path.components().collect();
let glob_idx = components.iter().position(|c| {
let s = c.as_os_str().to_string_lossy();
s.contains('*') || s.contains('?') || s.contains('[')
});
if let Some(idx) = glob_idx {
let base_components = &components[..idx];
let pattern_components = &components[idx..];
let base: PathBuf = base_components.iter().collect();
let pattern: String = pattern_components
.iter()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/");
(base, pattern)
} else {
(PathBuf::from("."), pattern.to_string())
}
} else {
(PathBuf::from("."), pattern.to_string())
};
let pattern_resolver = crate::pattern::PatternResolver::new();
let matches = pattern_resolver.resolve(&search_pattern, &base_path)?;
let (tool, target, flatten) = match dep {
ResourceDependency::Detailed(d) => (d.tool.clone(), d.target.clone(), d.flatten),
_ => (None, None, None),
};
let mut concrete_deps = Vec::new();
for matched_path in matches {
let resource_name = crate::pattern::extract_resource_name(&matched_path);
let absolute_path = base_path.join(&matched_path);
let concrete_path = absolute_path.to_string_lossy().to_string();
concrete_deps.push((
resource_name,
ResourceDependency::Detailed(Box::new(DetailedDependency {
source: None,
path: concrete_path,
version: None,
branch: None,
rev: None,
command: None,
args: None,
target: target.clone(),
filename: None,
dependencies: None,
tool: tool.clone(),
flatten,
install: None,
})),
));
}
Ok(concrete_deps)
} else {
let source_name = dep
.get_source()
.ok_or_else(|| anyhow::anyhow!("Pattern dependency missing source"))?;
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 '{}' @ '{}'. Pattern expansion requires prepared worktree.",
source_name,
version_key
)
})?;
let repo_path = &prepared.worktree_path;
let pattern_resolver = crate::pattern::PatternResolver::new();
let matches = pattern_resolver.resolve(pattern, Path::new(repo_path))?;
let mut concrete_deps = Vec::new();
for matched_path in matches {
let resource_name = crate::pattern::extract_resource_name(&matched_path);
let concrete_path = matched_path.to_string_lossy().to_string();
let (tool, target, flatten) = match dep {
ResourceDependency::Detailed(d) => {
(d.tool.clone(), d.target.clone(), d.flatten)
}
_ => (None, None, None),
};
concrete_deps.push((
resource_name,
ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some(source_name.to_string()),
path: concrete_path,
version: dep.get_version().map(std::string::ToString::to_string),
branch: None,
rev: None,
command: None,
args: None,
target, filename: None,
dependencies: None,
tool,
flatten,
install: None,
})),
));
}
Ok(concrete_deps)
}
}
fn generate_dependency_name(&self, path: &str) -> String {
let path = Path::new(path);
let without_ext = path.with_extension("");
let path_str = without_ext.to_string_lossy().replace('\\', "/");
let is_absolute = path.is_absolute();
let is_cross_directory = path_str.starts_with("../");
let components: Vec<&str> = path_str.split('/').collect();
if is_absolute || is_cross_directory {
path_str.to_string()
} else if components.len() > 1 {
components[1..].join("/")
} else {
components[0].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, crate::core::ResourceType)> = self
.manifest
.all_dependencies_with_types()
.into_iter()
.map(|(name, dep, resource_type)| (name.to_string(), dep.into_owned(), resource_type))
.collect();
for (name, dep, _) in &base_deps {
self.add_to_conflict_detector(name, dep, "manifest");
}
let base_deps_for_prep: Vec<(String, ResourceDependency)> =
base_deps.iter().map(|(name, dep, _)| (name.clone(), dep.clone())).collect();
self.prepare_remote_groups(&base_deps_for_prep).await?;
let deps = self.resolve_transitive_dependencies(&base_deps, enable_transitive).await?;
tracing::debug!("resolve_with_options - about to resolve {} dependencies", deps.len());
for (name, dep, resource_type) in &deps {
tracing::debug!(
"Resolving dependency: {} -> {} (type: {:?})",
name,
dep.get_path(),
resource_type
);
if dep.is_pattern() {
let entries = self.resolve_pattern_dependency(name, dep, *resource_type).await?;
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, *resource_type).await?;
tracing::debug!(
"Resolved {} to resource_type={:?}, installed_at={}",
name,
entry.resource_type,
entry.installed_at
);
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,
resource_type: crate::core::ResourceType,
) -> 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 filename = if let Some(custom_filename) = dep.get_filename() {
custom_filename.to_string()
} else {
extract_meaningful_path(Path::new(dep.get_path()))
};
let artifact_type_string = dep
.get_tool()
.map(|s| s.to_string())
.unwrap_or_else(|| self.manifest.get_default_tool(resource_type));
let artifact_type = artifact_type_string.as_str();
let unique_name = name.to_string();
let installed_at = match resource_type {
crate::core::ResourceType::Hook | crate::core::ResourceType::McpServer => {
if let Some(merge_target) =
self.manifest.get_merge_target(artifact_type, resource_type)
{
normalize_path_for_storage(merge_target.display().to_string())
} else {
match resource_type {
crate::core::ResourceType::Hook => {
".claude/settings.local.json".to_string()
}
crate::core::ResourceType::McpServer => {
if artifact_type == "opencode" {
".opencode/opencode.json".to_string()
} else {
".mcp.json".to_string()
}
}
_ => unreachable!(),
}
}
}
_ => {
let artifact_path = self
.manifest
.get_artifact_resource_path(artifact_type, resource_type)
.ok_or_else(|| {
let base_msg = format!(
"Resource type '{}' is not supported by tool '{}' for dependency '{}'",
resource_type, artifact_type, name
);
let resource_type_str = resource_type.to_string();
let hint = if ["claude-code", "opencode", "agpm"].contains(&resource_type_str.as_str()) {
format!(
"\n\nIt looks like '{}' is a tool name, not a resource type.\n\
In transitive dependencies, use resource types (agents, snippets, commands)\n\
as section headers, then specify 'tool: {}' within each dependency.",
resource_type_str, resource_type_str
)
} else {
format!(
"\n\nValid resource types: agent, command, snippet, hook, mcp-server, script\n\
Source file: {}",
dep.get_path()
)
};
anyhow::anyhow!("{}{}", base_msg, hint)
})?;
let flatten = dep
.get_flatten()
.or_else(|| {
self.manifest
.get_tool_config(artifact_type)
.and_then(|config| config.resources.get(resource_type.to_plural()))
.and_then(|resource_config| resource_config.flatten)
})
.unwrap_or(false);
let path = if let Some(custom_target) = dep.get_target() {
let base_target = PathBuf::from(artifact_path.display().to_string())
.join(custom_target.trim_start_matches('/'));
let relative_path = compute_relative_install_path(
&artifact_path,
Path::new(&filename),
flatten,
);
base_target.join(relative_path)
} else {
let relative_path = compute_relative_install_path(
&artifact_path,
Path::new(&filename),
flatten,
);
artifact_path.join(relative_path)
};
normalize_path_for_storage(normalize_path(&path))
}
};
Ok(LockedResource {
name: unique_name,
source: None,
url: None,
path: normalize_path_for_storage(dep.get_path()),
version: None,
resolved_commit: None,
checksum: String::new(),
installed_at,
dependencies: self.get_dependencies_for(name, None, resource_type, dep.get_tool()),
resource_type,
tool: Some(
dep.get_tool()
.map(std::string::ToString::to_string)
.unwrap_or_else(|| self.manifest.get_default_tool(resource_type)),
),
manifest_alias: self
.pattern_alias_map
.get(&(resource_type, name.to_string()))
.cloned(), applied_patches: HashMap::new(), install: dep.get_install(),
})
} 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 filename = if let Some(custom_filename) = dep.get_filename() {
custom_filename.to_string()
} else {
let dep_path = Path::new(dep.get_path());
dep_path.to_string_lossy().to_string()
};
let artifact_type_string = dep
.get_tool()
.map(std::string::ToString::to_string)
.unwrap_or_else(|| self.manifest.get_default_tool(resource_type));
let artifact_type = artifact_type_string.as_str();
let unique_name = name.to_string();
let installed_at = match resource_type {
crate::core::ResourceType::Hook | crate::core::ResourceType::McpServer => {
if let Some(merge_target) =
self.manifest.get_merge_target(artifact_type, resource_type)
{
normalize_path_for_storage(merge_target.display().to_string())
} else {
match resource_type {
crate::core::ResourceType::Hook => {
".claude/settings.local.json".to_string()
}
crate::core::ResourceType::McpServer => {
if artifact_type == "opencode" {
".opencode/opencode.json".to_string()
} else {
".mcp.json".to_string()
}
}
_ => unreachable!(),
}
}
}
_ => {
let artifact_path = self
.manifest
.get_artifact_resource_path(artifact_type, resource_type)
.ok_or_else(|| {
let base_msg = format!(
"Resource type '{}' is not supported by tool '{}' for dependency '{}'",
resource_type, artifact_type, name
);
let resource_type_str = resource_type.to_string();
let hint = if ["claude-code", "opencode", "agpm"].contains(&resource_type_str.as_str()) {
format!(
"\n\nIt looks like '{}' is a tool name, not a resource type.\n\
In transitive dependencies, use resource types (agents, snippets, commands)\n\
as section headers, then specify 'tool: {}' within each dependency.",
resource_type_str, resource_type_str
)
} else {
format!(
"\n\nValid resource types: agent, command, snippet, hook, mcp-server, script\n\
Source file: {}",
dep.get_path()
)
};
anyhow::anyhow!("{}{}", base_msg, hint)
})?;
let flatten = dep
.get_flatten()
.or_else(|| {
self.manifest
.get_tool_config(artifact_type)
.and_then(|config| config.resources.get(resource_type.to_plural()))
.and_then(|resource_config| resource_config.flatten)
})
.unwrap_or(false);
let path = if let Some(custom_target) = dep.get_target() {
let base_target = PathBuf::from(artifact_path.display().to_string())
.join(custom_target.trim_start_matches('/'));
let relative_path = compute_relative_install_path(
&artifact_path,
Path::new(&filename),
flatten,
);
base_target.join(relative_path)
} else {
let relative_path = compute_relative_install_path(
&artifact_path,
Path::new(&filename),
flatten,
);
artifact_path.join(relative_path)
};
normalize_path_for_storage(normalize_path(&path))
}
};
Ok(LockedResource {
name: unique_name,
source: Some(source_name.to_string()),
url: Some(source_url.clone()),
path: normalize_path_for_storage(dep.get_path()),
version: resolved_version, resolved_commit: Some(resolved_commit),
checksum: String::new(), installed_at,
dependencies: self.get_dependencies_for(
name,
Some(source_name),
resource_type,
dep.get_tool(),
),
resource_type,
tool: Some(artifact_type_string),
manifest_alias: self
.pattern_alias_map
.get(&(resource_type, name.to_string()))
.cloned(), applied_patches: HashMap::new(), install: dep.get_install(),
})
}
}
fn get_dependencies_for(
&self,
name: &str,
source: Option<&str>,
resource_type: crate::core::ResourceType,
tool: Option<&str>,
) -> Vec<String> {
let key = (
resource_type,
name.to_string(),
source.map(std::string::ToString::to_string),
tool.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,
resource_type: crate::core::ResourceType,
) -> 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 mut resources = Vec::new();
for matched_path in matches {
let resource_name = crate::pattern::extract_resource_name(&matched_path);
let artifact_type_string = dep
.get_tool()
.map(|s| s.to_string())
.unwrap_or_else(|| self.manifest.get_default_tool(resource_type));
let artifact_type = artifact_type_string.as_str();
let full_relative_path = if base_path == Path::new(".") {
crate::utils::normalize_path_for_storage(
matched_path.to_string_lossy().to_string(),
)
} else {
crate::utils::normalize_path_for_storage(format!(
"{}/{}",
base_path.display(),
matched_path.display()
))
};
let installed_at = match resource_type {
crate::core::ResourceType::Hook | crate::core::ResourceType::McpServer => {
if let Some(merge_target) =
self.manifest.get_merge_target(artifact_type, resource_type)
{
normalize_path_for_storage(merge_target.display().to_string())
} else {
match resource_type {
crate::core::ResourceType::Hook => {
".claude/settings.local.json".to_string()
}
crate::core::ResourceType::McpServer => {
if artifact_type == "opencode" {
".opencode/opencode.json".to_string()
} else {
".mcp.json".to_string()
}
}
_ => unreachable!(),
}
}
}
_ => {
let artifact_path = self
.manifest
.get_artifact_resource_path(artifact_type, resource_type)
.ok_or_else(|| {
anyhow::anyhow!(
"Resource type '{}' is not supported by tool '{}'",
resource_type,
artifact_type
)
})?;
let dep_flatten = dep.get_flatten();
let tool_flatten = self
.manifest
.get_tool_config(artifact_type)
.and_then(|config| config.resources.get(resource_type.to_plural()))
.and_then(|resource_config| resource_config.flatten);
let flatten = dep_flatten.or(tool_flatten).unwrap_or(false);
let base_target = if let Some(custom_target) = dep.get_target() {
PathBuf::from(artifact_path.display().to_string())
.join(custom_target.trim_start_matches('/'))
} else {
artifact_path.to_path_buf()
};
let filename = {
let full_path = if base_path == Path::new(".") {
matched_path.to_path_buf()
} else {
base_path.join(&matched_path)
};
extract_meaningful_path(&full_path)
};
let relative_path = compute_relative_install_path(
&base_target,
Path::new(&filename),
flatten,
);
normalize_path_for_storage(normalize_path(&base_target.join(relative_path)))
}
};
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,
dep.get_tool(),
),
resource_type,
tool: Some(
dep.get_tool()
.map(std::string::ToString::to_string)
.unwrap_or_else(|| self.manifest.get_default_tool(resource_type)),
),
manifest_alias: Some(name.to_string()), applied_patches: HashMap::new(), install: dep.get_install(),
});
}
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 mut resources = Vec::new();
for matched_path in matches {
let resource_name = crate::pattern::extract_resource_name(&matched_path);
let artifact_type_string = dep
.get_tool()
.map(|s| s.to_string())
.unwrap_or_else(|| self.manifest.get_default_tool(resource_type));
let artifact_type = artifact_type_string.as_str();
let installed_at = match resource_type {
crate::core::ResourceType::Hook | crate::core::ResourceType::McpServer => {
if let Some(merge_target) =
self.manifest.get_merge_target(artifact_type, resource_type)
{
normalize_path_for_storage(merge_target.display().to_string())
} else {
match resource_type {
crate::core::ResourceType::Hook => {
".claude/settings.local.json".to_string()
}
crate::core::ResourceType::McpServer => {
if artifact_type == "opencode" {
".opencode/opencode.json".to_string()
} else {
".mcp.json".to_string()
}
}
_ => unreachable!(),
}
}
}
_ => {
let artifact_path = self
.manifest
.get_artifact_resource_path(artifact_type, resource_type)
.ok_or_else(|| {
anyhow::anyhow!(
"Resource type '{}' is not supported by tool '{}'",
resource_type,
artifact_type
)
})?;
let dep_flatten = dep.get_flatten();
let tool_flatten = self
.manifest
.get_tool_config(artifact_type)
.and_then(|config| config.resources.get(resource_type.to_plural()))
.and_then(|resource_config| resource_config.flatten);
let flatten = dep_flatten.or(tool_flatten).unwrap_or(false);
let base_target = if let Some(custom_target) = dep.get_target() {
PathBuf::from(artifact_path.display().to_string())
.join(custom_target.trim_start_matches('/'))
} else {
artifact_path.to_path_buf()
};
let filename = {
let full_path = repo_path_ref.join(&matched_path);
let components: Vec<_> = full_path.components().collect();
if full_path.is_absolute() {
let mut resolved = Vec::new();
for component in components.iter() {
match component {
std::path::Component::Normal(name) => {
resolved.push(name.to_str().unwrap_or(""));
}
std::path::Component::ParentDir => {
resolved.pop();
}
_ => {}
}
}
resolved.join("/")
} else if components
.iter()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
let start_idx = components
.iter()
.position(|c| matches!(c, std::path::Component::Normal(_)))
.unwrap_or(0);
components[start_idx..]
.iter()
.filter_map(|c| c.as_os_str().to_str())
.collect::<Vec<_>>()
.join("/")
} else {
full_path.to_str().unwrap_or("").replace('\\', "/") }
};
let relative_path = compute_relative_install_path(
&base_target,
Path::new(&filename),
flatten,
);
normalize_path_for_storage(normalize_path(&base_target.join(relative_path)))
}
};
resources.push(LockedResource {
name: resource_name.clone(),
source: Some(source_name.to_string()),
url: Some(source_url.clone()),
path: normalize_path_for_storage(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,
dep.get_tool(),
),
resource_type,
tool: Some(
dep.get_tool()
.map(|s| s.to_string())
.unwrap_or_else(|| self.manifest.get_default_tool(resource_type)),
),
manifest_alias: Some(name.to_string()), applied_patches: HashMap::new(), install: dep.get_install(),
});
}
Ok(resources)
}
}
async 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 self
.resolve_semver_range_conflict(resource_name, existing, new_dep, requester)
.await;
}
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())
}
}
}
async fn resolve_semver_range_conflict(
&self,
resource_name: &str,
existing: &ResourceDependency,
new_dep: &ResourceDependency,
requester: &str,
) -> Result<ResourceDependency> {
use crate::manifest::DetailedDependency;
use crate::resolver::version_resolution::parse_tags_to_versions;
use semver::Version;
use std::collections::HashMap;
let existing_version = existing.get_version().unwrap_or("HEAD");
let new_version = new_dep.get_version().unwrap_or("HEAD");
tracing::info!(
"Resolving semver range conflict for '{}': existing '{}' vs required '{}' by '{}'",
resource_name,
existing_version,
new_version,
requester
);
let source = existing.get_source().or_else(|| new_dep.get_source()).ok_or_else(|| {
AgpmError::Other {
message: format!(
"Cannot resolve semver ranges for local dependencies: {}",
resource_name
),
}
})?;
let repo_path =
self.version_resolver.get_bare_repo_path(source).ok_or_else(|| AgpmError::Other {
message: format!("Source '{}' not synced yet", source),
})?;
let tags = self.get_available_versions(repo_path).await?;
tracing::debug!("Found {} tags for source '{}'", tags.len(), source);
let parsed_versions = parse_tags_to_versions(tags);
let available_versions: Vec<Version> =
parsed_versions.iter().map(|(_, v)| v.clone()).collect();
if available_versions.is_empty() {
return Err(AgpmError::Other {
message: format!(
"No valid semver tags found for source '{}' to resolve range conflict",
source
),
}
.into());
}
let mut detector = ConflictDetector::new();
let resource_id = format!("{}:{}", source, existing.get_path());
detector.add_requirement(&resource_id, "existing", existing_version);
detector.add_requirement(&resource_id, requester, new_version);
let conflicts = detector.detect_conflicts();
if !conflicts.is_empty() {
return Err(AgpmError::Other {
message: format!(
"Incompatible version ranges for '{}': existing '{}' vs required '{}' by '{}'",
resource_name, existing_version, new_version, requester
),
}
.into());
}
let mut versions_map = HashMap::new();
versions_map.insert(resource_id.clone(), available_versions);
let resolved = detector.resolve_conflicts(&versions_map)?;
let best_version = resolved.get(&resource_id).ok_or_else(|| AgpmError::Other {
message: format!("Failed to resolve version for '{}'", resource_name),
})?;
let best_tag = parsed_versions
.iter()
.find(|(_, v)| v == best_version)
.map(|(tag, _)| tag.clone())
.ok_or_else(|| AgpmError::Other {
message: format!("Version {} not found in tags", best_version),
})?;
tracing::info!(
"Resolved '{}' to version {} (satisfies both '{}' and '{}')",
resource_name,
best_tag,
existing_version,
new_version
);
match new_dep {
ResourceDependency::Detailed(d) => {
let mut resolved_dep = (**d).clone();
resolved_dep.version = Some(best_tag);
resolved_dep.branch = None; resolved_dep.rev = None;
Ok(ResourceDependency::Detailed(Box::new(resolved_dep)))
}
ResourceDependency::Simple(_) => {
Ok(ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some(source.to_string()),
path: existing.get_path().to_string(),
version: Some(best_tag),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: Some("claude-code".to_string()), flatten: None,
install: None,
})))
}
}
}
pub async fn update(
&mut self,
existing: &LockFile,
deps_to_update: Option<Vec<String>>,
) -> Result<LockFile> {
let mut lockfile = existing.clone();
lockfile.sources.clear();
for (name, url) in &self.manifest.sources {
lockfile.add_source(name.clone(), url.clone(), String::new());
}
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, crate::core::ResourceType)> = self
.manifest
.all_dependencies_with_types()
.into_iter()
.map(|(name, dep, resource_type)| (name.to_string(), dep.into_owned(), resource_type))
.collect();
let base_deps_for_prep: Vec<(String, ResourceDependency)> =
base_deps.iter().map(|(name, dep, _)| (name.clone(), dep.clone())).collect();
self.prepare_remote_groups(&base_deps_for_prep).await?;
self.remove_stale_manifest_entries(&mut lockfile);
self.remove_manifest_entries_for_update(&mut lockfile, &deps_to_check);
let deps = self.resolve_transitive_dependencies(&base_deps, true).await?;
for (name, dep, resource_type) in deps {
let is_manifest_dep = self.manifest.all_dependencies().iter().any(|(n, _)| *n == name);
let pattern_alias = self.pattern_alias_map.get(&(resource_type, name.clone()));
let should_skip = is_manifest_dep
&& !deps_to_check.contains(&name)
&& !pattern_alias.is_some_and(|alias| deps_to_check.contains(alias));
if should_skip {
continue;
}
if dep.is_pattern() {
let entries = self.resolve_pattern_dependency(&name, &dep, resource_type).await?;
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, resource_type).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<(&str, std::borrow::Cow<'_, ResourceDependency>, crate::core::ResourceType)> =
self.manifest.all_dependencies_with_types();
for (name, dep, _resource_type) 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(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::DetailedDependency;
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 temp_dir = TempDir::new().unwrap();
let mut manifest = Manifest::new();
manifest.manifest_dir = Some(temp_dir.path().to_path_buf());
manifest.add_dependency(
"local-agent".to_string(),
ResourceDependency::Simple("../agents/local.md".to_string()),
true,
);
let agents_dir = temp_dir.path().parent().unwrap().join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
std::fs::write(agents_dir.join("local.md"), "# Local Agent").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());
}
#[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: Some("claude-code".to_string()),
flatten: None,
install: None,
})),
true,
);
let cache_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(cache_dir.path().to_path_buf()).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest.clone(), cache);
let deps: Vec<(String, ResourceDependency)> = manifest
.all_dependencies()
.into_iter()
.map(|(name, dep)| (name.to_string(), dep.clone()))
.collect();
resolver.pre_sync_sources(&deps).await.unwrap();
assert!(
resolver.version_resolver.pending_count() > 0,
"Should have entries after pre-sync"
);
let bare_repo = resolver.version_resolver.get_bare_repo_path("test-source");
assert!(bare_repo.is_some(), "Should have bare repo path cached");
let cached_repo_path = resolver.cache.get_cache_location().join("sources");
let mut found_repo = false;
if let Ok(entries) = std::fs::read_dir(&cached_repo_path) {
for entry in entries.flatten() {
if let Some(name) = entry.file_name().to_str()
&& name.ends_with(".git")
{
found_repo = true;
break;
}
}
}
assert!(found_repo, "Repository should be cloned to cache");
resolver.version_resolver.resolve_all().await.unwrap();
let all_resolved = resolver.version_resolver.get_all_resolved();
assert!(!all_resolved.is_empty(), "Resolution should produce resolved versions");
let key = ("test-source".to_string(), "v1.0.0".to_string());
assert!(all_resolved.contains_key(&key), "Should have resolved v1.0.0");
let sha = all_resolved.get(&key).unwrap();
assert_eq!(sha.len(), 40, "SHA should be 40 characters");
}
#[test]
fn test_verify_missing_source() {
let mut manifest = Manifest::new();
manifest.add_dependency(
"remote-agent".to_string(),
ResourceDependency::Detailed(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: Some("claude-code".to_string()),
flatten: None,
install: None,
})),
true,
);
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let result = resolver.verify();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("undefined source"));
}
#[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: Some("claude-code".to_string()),
flatten: None,
install: None,
})),
true,
);
let cache_dir = temp_dir.path().join("cache");
let cache = Cache::with_dir(cache_dir).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let result = resolver.resolve().await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_resolve_with_progress() {
let temp_dir = TempDir::new().unwrap();
let mut manifest = Manifest::new();
manifest.manifest_dir = Some(temp_dir.path().to_path_buf());
manifest.add_dependency(
"local".to_string(),
ResourceDependency::Simple("test.md".to_string()),
true,
);
std::fs::write(temp_dir.path().join("test.md"), "# Test").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: Some("claude-code".to_string()),
flatten: None,
install: None,
})),
true,
);
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let result = resolver.verify();
assert!(result.is_ok());
}
#[tokio::test]
async fn test_resolve_with_git_ref() {
let temp_dir = TempDir::new().unwrap();
let source_dir = temp_dir.path().join("test-source");
std::fs::create_dir_all(&source_dir).unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(&source_dir)
.output()
.expect("Failed to initialize git repository");
std::process::Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&source_dir)
.output()
.unwrap();
let agents_dir = source_dir.join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
std::fs::write(agents_dir.join("test.md"), "# Test Agent").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["branch", "-M", "main"])
.current_dir(&source_dir)
.output()
.unwrap();
let mut manifest = Manifest::new();
let source_url = 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: Some("claude-code".to_string()),
flatten: None,
install: None,
})),
true,
);
let cache_dir = temp_dir.path().join("cache");
let cache = Cache::with_dir(cache_dir).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let result = resolver.resolve().await;
if let Err(e) = &result {
eprintln!("Test failed with error: {:#}", e);
}
assert!(result.is_ok());
}
#[tokio::test]
async fn test_new_with_global() {
let manifest = Manifest::new();
let cache = Cache::new().unwrap();
let result = DependencyResolver::new_with_global(manifest, cache).await;
assert!(result.is_ok());
}
#[test]
fn test_resolver_new_default() {
let manifest = Manifest::new();
let cache = Cache::new().unwrap();
let result = DependencyResolver::new(manifest, cache);
assert!(result.is_ok());
}
#[tokio::test]
async fn test_resolve_multiple_dependencies() {
let temp_dir = TempDir::new().unwrap();
let mut manifest = Manifest::new();
manifest.manifest_dir = Some(temp_dir.path().to_path_buf());
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,
);
std::fs::write(temp_dir.path().join("a1.md"), "# Agent 1").unwrap();
std::fs::write(temp_dir.path().join("a2.md"), "# Agent 2").unwrap();
std::fs::write(temp_dir.path().join("s1.md"), "# Snippet 1").unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let lockfile = resolver.resolve().await.unwrap();
assert_eq!(lockfile.agents.len(), 2);
assert_eq!(lockfile.snippets.len(), 1);
}
#[test]
fn test_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 temp_dir = TempDir::new().unwrap();
let mut manifest = Manifest::new();
manifest.manifest_dir = Some(temp_dir.path().to_path_buf());
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: Some("claude-code".to_string()),
flatten: None,
install: None,
})),
true,
);
std::fs::write(temp_dir.path().parent().unwrap().join("test.md"), "# Test").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 = normalize_path_for_storage(&agent.installed_at);
assert!(normalized_path.contains(".claude/agents/integrations/custom"));
assert_eq!(normalized_path, ".claude/agents/integrations/custom/test.md");
}
#[tokio::test]
async fn test_resolve_without_custom_target() {
let temp_dir = TempDir::new().unwrap();
let mut manifest = Manifest::new();
manifest.manifest_dir = Some(temp_dir.path().to_path_buf());
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: Some("claude-code".to_string()),
flatten: None,
install: None,
})),
true,
);
std::fs::write(temp_dir.path().parent().unwrap().join("test.md"), "# Test").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 = normalize_path_for_storage(&agent.installed_at);
assert_eq!(normalized_path, ".claude/agents/test.md");
}
#[tokio::test]
async fn test_resolve_with_custom_filename() {
let temp_dir = TempDir::new().unwrap();
let mut manifest = Manifest::new();
manifest.manifest_dir = Some(temp_dir.path().to_path_buf());
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: Some("claude-code".to_string()),
flatten: None,
install: None,
})),
true,
);
std::fs::write(temp_dir.path().parent().unwrap().join("test.md"), "# Test").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 = normalize_path_for_storage(&agent.installed_at);
assert_eq!(normalized_path, ".claude/agents/ai-assistant.txt");
}
#[tokio::test]
async fn test_resolve_with_custom_filename_and_target() {
let temp_dir = TempDir::new().unwrap();
let mut manifest = Manifest::new();
manifest.manifest_dir = Some(temp_dir.path().to_path_buf());
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: Some("claude-code".to_string()),
flatten: None,
install: None,
})),
true,
);
std::fs::write(temp_dir.path().parent().unwrap().join("test.md"), "# Test").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 = normalize_path_for_storage(&agent.installed_at);
assert_eq!(normalized_path, ".claude/agents/tools/ai/assistant.markdown");
}
#[tokio::test]
async fn test_resolve_script_with_custom_filename() {
let temp_dir = TempDir::new().unwrap();
let mut manifest = Manifest::new();
manifest.manifest_dir = Some(temp_dir.path().to_path_buf());
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: Some("claude-code".to_string()),
flatten: None,
install: None,
})),
false, );
let scripts_dir = temp_dir.path().parent().unwrap().join("scripts");
std::fs::create_dir_all(&scripts_dir).unwrap();
std::fs::write(scripts_dir.join("data-analyzer-v3.py"), "#!/usr/bin/env python3").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 = normalize_path_for_storage(&script.installed_at);
assert_eq!(normalized_path, ".claude/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.manifest_dir = Some(project_dir.to_path_buf());
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: Some("claude-code".to_string()),
flatten: None,
install: None,
})),
true, );
let cache_dir = temp_dir.path().join("cache");
let cache = Cache::with_dir(cache_dir).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let lockfile = resolver.resolve().await.unwrap();
assert_eq!(lockfile.agents.len(), 2);
let agent_names: Vec<String> = lockfile.agents.iter().map(|a| a.name.clone()).collect();
assert!(agent_names.contains(&"python-linter".to_string()));
assert!(agent_names.contains(&"python-formatter".to_string()));
assert!(!agent_names.contains(&"rust-linter".to_string()));
}
#[tokio::test]
async fn test_resolve_pattern_dependency_with_custom_target() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path();
let agents_dir = project_dir.join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
std::fs::write(agents_dir.join("helper.md"), "# Helper Agent").unwrap();
std::fs::write(agents_dir.join("assistant.md"), "# Assistant Agent").unwrap();
let mut manifest = Manifest::new();
manifest.manifest_dir = Some(project_dir.to_path_buf());
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: Some("claude-code".to_string()),
flatten: None,
install: None,
})),
true,
);
let cache_dir = temp_dir.path().join("cache");
let cache = Cache::with_dir(cache_dir).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let lockfile = resolver.resolve().await.unwrap();
assert_eq!(lockfile.agents.len(), 2);
for agent in &lockfile.agents {
assert!(agent.installed_at.starts_with(".claude/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: Some("claude-code".to_string()),
flatten: None,
install: None,
})),
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: Some("claude-code".to_string()),
flatten: None,
install: None,
})),
true,
);
let cache_dir = temp_dir.path().join("cache");
let cache = Cache::with_dir(cache_dir.clone()).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest.clone(), cache);
let initial_lockfile = resolver.resolve().await.unwrap();
assert_eq!(initial_lockfile.agents.len(), 2);
let mut updated_manifest = Manifest::new();
updated_manifest.add_source("test".to_string(), 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: Some("claude-code".to_string()),
flatten: None,
install: None,
})),
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: Some("claude-code".to_string()),
flatten: None,
install: None,
})),
true,
);
let cache2 = Cache::with_dir(cache_dir).unwrap();
let mut resolver2 = DependencyResolver::with_cache(updated_manifest, cache2);
let updated_lockfile =
resolver2.update(&initial_lockfile, Some(vec!["agent1".to_string()])).await.unwrap();
let agent1 = updated_lockfile
.agents
.iter()
.find(|a| a.name == "agent1" && 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 temp_dir = TempDir::new().unwrap();
let mut manifest = Manifest::new();
manifest.manifest_dir = Some(temp_dir.path().to_path_buf());
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 parent = temp_dir.path().parent().unwrap();
std::fs::write(parent.join("a1.md"), "# Agent 1").unwrap();
std::fs::write(parent.join("a2.md"), "# Agent 2").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 temp_dir = TempDir::new().unwrap();
let mut manifest = Manifest::new();
manifest.manifest_dir = Some(temp_dir.path().to_path_buf());
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 hooks_dir = temp_dir.path().parent().unwrap().join("hooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(hooks_dir.join("pre-commit.json"), "{}").unwrap();
std::fs::write(hooks_dir.join("post-commit.json"), "{}").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 temp_dir = TempDir::new().unwrap();
let mut manifest = Manifest::new();
manifest.manifest_dir = Some(temp_dir.path().to_path_buf());
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 scripts_dir = temp_dir.path().parent().unwrap().join("scripts");
std::fs::create_dir_all(&scripts_dir).unwrap();
std::fs::write(scripts_dir.join("build.sh"), "#!/bin/bash").unwrap();
std::fs::write(scripts_dir.join("test.py"), "#!/usr/bin/env python3").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 temp_dir = TempDir::new().unwrap();
let mut manifest = Manifest::new();
manifest.manifest_dir = Some(temp_dir.path().to_path_buf());
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 mcp_dir = temp_dir.path().parent().unwrap().join("mcp");
std::fs::create_dir_all(&mcp_dir).unwrap();
std::fs::write(mcp_dir.join("filesystem.json"), "{}").unwrap();
std::fs::write(mcp_dir.join("database.json"), "{}").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 temp_dir = TempDir::new().unwrap();
let mut manifest = Manifest::new();
manifest.manifest_dir = Some(temp_dir.path().to_path_buf());
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 commands_dir = temp_dir.path().parent().unwrap().join("commands");
std::fs::create_dir_all(&commands_dir).unwrap();
std::fs::write(commands_dir.join("deploy.md"), "# Deploy").unwrap();
std::fs::write(commands_dir.join("lint.md"), "# Lint").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 = normalize_path_for_storage(&command.installed_at);
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: Some("claude-code".to_string()),
flatten: None,
install: None,
})),
true,
);
let cache_dir = temp_dir.path().join("cache");
let cache = Cache::with_dir(cache_dir).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let lockfile = resolver.resolve().await.unwrap();
assert_eq!(lockfile.agents.len(), 1);
let agent = &lockfile.agents[0];
assert_eq!(agent.version.as_ref().unwrap(), "v1.2.0");
}
#[tokio::test]
async fn test_verify_absolute_path_error() {
let mut manifest = Manifest::new();
let nonexistent_path = if cfg!(windows) {
"C:\\nonexistent\\path\\agent.md"
} else {
"/nonexistent/path/agent.md"
};
manifest.add_dependency(
"missing-agent".to_string(),
ResourceDependency::Simple(nonexistent_path.to_string()),
true,
);
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let result = resolver.verify();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[tokio::test]
async fn test_resolve_pattern_dependency_error() {
let mut manifest = Manifest::new();
manifest.add_dependency(
"pattern-dep".to_string(),
ResourceDependency::Detailed(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: Some("claude-code".to_string()),
flatten: None,
install: None,
})),
true,
);
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let result = resolver.resolve().await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_checkout_version_with_branch() {
let temp_dir = TempDir::new().unwrap();
let source_dir = temp_dir.path().join("test-source");
std::fs::create_dir_all(&source_dir).unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(&source_dir)
.output()
.expect("Failed to initialize git repository");
std::process::Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&source_dir)
.output()
.unwrap();
let test_file = source_dir.join("test.txt");
std::fs::write(&test_file, "main").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "Initial"])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["checkout", "-b", "develop"])
.current_dir(&source_dir)
.output()
.unwrap();
std::fs::write(&test_file, "develop").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "Develop commit"])
.current_dir(&source_dir)
.output()
.unwrap();
let mut manifest = Manifest::new();
let source_url = 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: Some("claude-code".to_string()),
flatten: None,
install: None,
})),
true,
);
let cache_dir = temp_dir.path().join("cache");
let cache = Cache::with_dir(cache_dir).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let lockfile = resolver.resolve().await.unwrap();
assert_eq!(lockfile.agents.len(), 1);
let agent = &lockfile.agents[0];
assert!(agent.resolved_commit.is_some());
}
#[tokio::test]
async fn test_checkout_version_with_commit_hash() {
let temp_dir = TempDir::new().unwrap();
let source_dir = temp_dir.path().join("test-source");
std::fs::create_dir_all(&source_dir).unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(&source_dir)
.output()
.expect("Failed to initialize git repository");
std::process::Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&source_dir)
.output()
.unwrap();
let test_file = source_dir.join("test.txt");
std::fs::write(&test_file, "content").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&source_dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "Test commit"])
.current_dir(&source_dir)
.output()
.unwrap();
let output = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&source_dir)
.output()
.unwrap();
let commit_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
let mut manifest = Manifest::new();
let source_url = 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: Some("claude-code".to_string()),
flatten: None,
install: None,
})),
true,
);
let cache_dir = temp_dir.path().join("cache");
let cache = Cache::with_dir(cache_dir).unwrap();
let mut resolver = DependencyResolver::with_cache(manifest, cache);
let lockfile = resolver.resolve().await.unwrap();
assert_eq!(lockfile.agents.len(), 1);
let agent = &lockfile.agents[0];
assert!(agent.resolved_commit.is_some());
assert!(agent.resolved_commit.as_ref().unwrap().starts_with(&commit_hash[..7]));
}
#[tokio::test]
async fn test_mixed_resource_types() {
let temp_dir = TempDir::new().unwrap();
let mut manifest = Manifest::new();
manifest.manifest_dir = Some(temp_dir.path().to_path_buf());
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 parent = temp_dir.path().parent().unwrap();
std::fs::create_dir_all(parent.join("agents")).unwrap();
std::fs::create_dir_all(parent.join("scripts")).unwrap();
std::fs::create_dir_all(parent.join("hooks")).unwrap();
std::fs::create_dir_all(parent.join("commands")).unwrap();
std::fs::create_dir_all(parent.join("mcp")).unwrap();
std::fs::write(parent.join("agents/a1.md"), "# Agent").unwrap();
std::fs::write(parent.join("scripts/build.sh"), "#!/bin/bash").unwrap();
std::fs::write(parent.join("hooks/pre-commit.json"), "{}").unwrap();
std::fs::write(parent.join("commands/deploy.md"), "# Deploy").unwrap();
std::fs::write(parent.join("mcp/filesystem.json"), "{}").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);
}
#[tokio::test]
async 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: Some("claude-code".to_string()),
flatten: None,
install: None,
}));
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: Some("claude-code".to_string()),
flatten: None,
install: None,
}));
let result = resolver
.resolve_version_conflict("test-agent", &existing_semver, &new_branch, "app1")
.await;
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")
.await;
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)"
);
}
#[tokio::test]
async 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: Some("claude-code".to_string()),
flatten: None,
install: None,
}));
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: Some("claude-code".to_string()),
flatten: None,
install: None,
}));
let result =
resolver.resolve_version_conflict("test-agent", &existing_v1, &new_v2, "app1").await;
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").await;
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)"
);
}
#[tokio::test]
async 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: Some("claude-code".to_string()),
flatten: None,
install: None,
}));
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: Some("claude-code".to_string()),
flatten: None,
install: None,
}));
let result = resolver
.resolve_version_conflict("test-agent", &existing_main, &new_develop, "app1")
.await;
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"
);
}
#[tokio::test]
async 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: Some("claude-code".to_string()),
flatten: None,
install: None,
}));
let result = resolver
.resolve_version_conflict("test-agent", &existing_head, &new_specific, "app1")
.await;
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"
);
}
#[test]
fn test_generate_dependency_name_manifest_relative() {
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.generate_dependency_name("agents/helper.md"), "helper");
assert_eq!(resolver.generate_dependency_name("snippets/utils.md"), "utils");
assert_eq!(resolver.generate_dependency_name("../shared/utils.md"), "../shared/utils");
assert_eq!(resolver.generate_dependency_name("../other/utils.md"), "../other/utils");
let name1 = resolver.generate_dependency_name("../shared/foo.md");
let name2 = resolver.generate_dependency_name("../other/foo.md");
assert_ne!(name1, name2, "Different parent directories should produce different names");
}
}