pub mod dependency_spec;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub use dependency_spec::{DependencyMetadata, DependencySpec};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub sources: HashMap<String, String>,
#[deprecated(since = "0.4.0", note = "Use tools configuration instead")]
#[serde(default)]
pub target: TargetConfig,
#[serde(rename = "tools", skip_serializing_if = "Option::is_none")]
pub tools: Option<ToolsConfig>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub agents: HashMap<String, ResourceDependency>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub snippets: HashMap<String, ResourceDependency>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub commands: HashMap<String, ResourceDependency>,
#[serde(default, skip_serializing_if = "HashMap::is_empty", rename = "mcp-servers")]
pub mcp_servers: HashMap<String, ResourceDependency>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub scripts: HashMap<String, ResourceDependency>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub hooks: HashMap<String, ResourceDependency>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ResourceConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArtifactTypeConfig {
pub path: PathBuf,
pub resources: HashMap<String, ResourceConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolsConfig {
#[serde(flatten)]
pub types: HashMap<String, ArtifactTypeConfig>,
}
impl Default for ToolsConfig {
fn default() -> Self {
use crate::core::ResourceType;
let mut types = HashMap::new();
let mut claude_resources = HashMap::new();
claude_resources.insert(
ResourceType::Agent.to_plural().to_string(),
ResourceConfig {
path: Some("agents".to_string()),
},
);
claude_resources.insert(
ResourceType::Snippet.to_plural().to_string(),
ResourceConfig {
path: Some("agpm/snippets".to_string()),
},
);
claude_resources.insert(
ResourceType::Command.to_plural().to_string(),
ResourceConfig {
path: Some("commands".to_string()),
},
);
claude_resources.insert(
ResourceType::Script.to_plural().to_string(),
ResourceConfig {
path: Some("agpm/scripts".to_string()),
},
);
claude_resources.insert(
ResourceType::Hook.to_plural().to_string(),
ResourceConfig {
path: Some("agpm/hooks".to_string()),
},
);
claude_resources.insert(
ResourceType::McpServer.to_plural().to_string(),
ResourceConfig {
path: Some("agpm/mcp-servers".to_string()),
},
);
types.insert(
"claude-code".to_string(),
ArtifactTypeConfig {
path: PathBuf::from(".claude"),
resources: claude_resources,
},
);
let mut opencode_resources = HashMap::new();
opencode_resources.insert(
ResourceType::Agent.to_plural().to_string(),
ResourceConfig {
path: Some("agent".to_string()), },
);
opencode_resources.insert(
ResourceType::Command.to_plural().to_string(),
ResourceConfig {
path: Some("command".to_string()), },
);
opencode_resources.insert(
ResourceType::McpServer.to_plural().to_string(),
ResourceConfig {
path: Some("agpm/mcp-servers".to_string()), },
);
types.insert(
"opencode".to_string(),
ArtifactTypeConfig {
path: PathBuf::from(".opencode"),
resources: opencode_resources,
},
);
let mut agpm_resources = HashMap::new();
agpm_resources.insert(
ResourceType::Snippet.to_plural().to_string(),
ResourceConfig {
path: Some("snippets".to_string()),
},
);
types.insert(
"agpm".to_string(),
ArtifactTypeConfig {
path: PathBuf::from(".agpm"),
resources: agpm_resources,
},
);
Self {
types,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TargetConfig {
#[serde(default = "default_agents_dir")]
pub agents: String,
#[serde(default = "default_snippets_dir")]
pub snippets: String,
#[serde(default = "default_commands_dir")]
pub commands: String,
#[serde(default = "default_mcp_servers_dir", rename = "mcp-servers")]
pub mcp_servers: String,
#[serde(default = "default_scripts_dir")]
pub scripts: String,
#[serde(default = "default_hooks_dir")]
pub hooks: String,
#[serde(default = "default_gitignore")]
pub gitignore: bool,
}
impl Default for TargetConfig {
fn default() -> Self {
Self {
agents: default_agents_dir(),
snippets: default_snippets_dir(),
commands: default_commands_dir(),
mcp_servers: default_mcp_servers_dir(),
scripts: default_scripts_dir(),
hooks: default_hooks_dir(),
gitignore: default_gitignore(),
}
}
}
fn default_agents_dir() -> String {
".claude/agents".to_string()
}
fn default_snippets_dir() -> String {
".claude/agpm/snippets".to_string()
}
fn default_commands_dir() -> String {
".claude/commands".to_string()
}
fn default_mcp_servers_dir() -> String {
".claude/agpm/mcp-servers".to_string()
}
fn default_scripts_dir() -> String {
".claude/agpm/scripts".to_string()
}
fn default_hooks_dir() -> String {
".claude/agpm/hooks".to_string()
}
const fn default_gitignore() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ResourceDependency {
Simple(String),
Detailed(Box<DetailedDependency>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetailedDependency {
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rev: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub args: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dependencies: Option<HashMap<String, Vec<DependencySpec>>>,
#[serde(default = "default_tool", skip_serializing_if = "is_default_tool")]
pub tool: String,
}
fn default_tool() -> String {
"claude-code".to_string()
}
fn is_default_tool(tool: &str) -> bool {
tool == "claude-code"
}
impl Manifest {
#[must_use]
#[allow(deprecated)]
pub fn new() -> Self {
Self {
sources: HashMap::new(),
target: TargetConfig::default(),
tools: None,
agents: HashMap::new(),
snippets: HashMap::new(),
commands: HashMap::new(),
mcp_servers: HashMap::new(),
scripts: HashMap::new(),
hooks: HashMap::new(),
}
}
pub fn load(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path).with_context(|| {
format!(
"Cannot read manifest file: {}\n\n\
Possible causes:\n\
- File doesn't exist or has been moved\n\
- Permission denied (check file ownership)\n\
- File is locked by another process",
path.display()
)
})?;
let mut manifest: Self = toml::from_str(&content)
.map_err(|e| crate::core::AgpmError::ManifestParseError {
file: path.display().to_string(),
reason: e.to_string(),
})
.with_context(|| {
format!(
"Invalid TOML syntax in manifest file: {}\n\n\
Common TOML syntax errors:\n\
- Missing quotes around strings\n\
- Unmatched brackets [ ] or braces {{ }}\n\
- Invalid characters in keys or values\n\
- Incorrect indentation or structure",
path.display()
)
})?;
manifest.apply_tool_defaults();
manifest.validate()?;
Ok(manifest)
}
fn apply_tool_defaults(&mut self) {
for dependency in self.snippets.values_mut() {
if let ResourceDependency::Detailed(details) = dependency {
if details.tool == "claude-code" {
details.tool = "agpm".to_string();
}
}
}
}
pub fn save(&self, path: &Path) -> Result<()> {
let content = toml::to_string_pretty(self)
.with_context(|| "Failed to serialize manifest data to TOML format")?;
std::fs::write(path, content).with_context(|| {
format!(
"Cannot write manifest file: {}\n\n\
Possible causes:\n\
- Permission denied (try running with elevated permissions)\n\
- Directory doesn't exist\n\
- Disk is full or read-only\n\
- File is locked by another process",
path.display()
)
})?;
Ok(())
}
pub fn validate(&self) -> Result<()> {
for artifact_type in self.get_tools_config().types.keys() {
if artifact_type.contains('/') || artifact_type.contains('\\') {
return Err(crate::core::AgpmError::ManifestValidationError {
reason: format!(
"Artifact type name '{artifact_type}' cannot contain path separators ('/' or '\\\\'). \n\
Artifact type names must be simple identifiers without special characters."
),
}
.into());
}
if artifact_type.contains("..") {
return Err(crate::core::AgpmError::ManifestValidationError {
reason: format!(
"Artifact type name '{artifact_type}' cannot contain '..' (path traversal). \n\
Artifact type names must be simple identifiers."
),
}
.into());
}
}
for (name, dep) in self.all_dependencies() {
if dep.get_path().is_empty() {
return Err(crate::core::AgpmError::ManifestValidationError {
reason: format!("Missing required field 'path' for dependency '{name}'"),
}
.into());
}
if dep.is_pattern() {
crate::pattern::validate_pattern_safety(dep.get_path()).map_err(|e| {
crate::core::AgpmError::ManifestValidationError {
reason: format!("Invalid pattern in dependency '{name}': {e}"),
}
})?;
}
if let Some(source) = dep.get_source() {
if !self.sources.contains_key(source) {
return Err(crate::core::AgpmError::SourceNotFound {
name: source.to_string(),
}
.into());
}
let source_url = self.sources.get(source).unwrap();
let _is_local_source = source_url.starts_with('/')
|| source_url.starts_with("./")
|| source_url.starts_with("../");
} else {
if !dep.is_pattern() {
let path = dep.get_path();
let is_plain_dir =
path.starts_with('/') || path.starts_with("./") || path.starts_with("../");
if is_plain_dir && dep.get_version().is_some() {
return Err(crate::core::AgpmError::ManifestValidationError {
reason: format!(
"Version specified for plain directory dependency '{name}' with path '{path}'. \n\
Plain directory dependencies do not support versions. \n\
Remove the 'version' field or use a git source instead."
),
}
.into());
}
}
}
}
let mut seen_deps: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for (name, dep) in self.all_dependencies() {
if let Some(version) = dep.get_version() {
if let Some(existing_version) = seen_deps.get(name) {
if existing_version != version {
return Err(crate::core::AgpmError::ManifestValidationError {
reason: format!(
"Version conflict for dependency '{name}': found versions '{existing_version}' and '{version}'"
),
}
.into());
}
} else {
seen_deps.insert(name.to_string(), version.to_string());
}
}
}
for (name, url) in &self.sources {
let expanded_url = expand_url(url)?;
if !expanded_url.starts_with("http://")
&& !expanded_url.starts_with("https://")
&& !expanded_url.starts_with("git@")
&& !expanded_url.starts_with("file://")
&& !expanded_url.starts_with('/')
&& !expanded_url.starts_with("./")
&& !expanded_url.starts_with("../")
{
return Err(crate::core::AgpmError::ManifestValidationError {
reason: format!("Source '{name}' has invalid URL: '{url}'. Must be HTTP(S), SSH (git@...), or file:// URL"),
}
.into());
}
if expanded_url.starts_with('/')
|| expanded_url.starts_with("./")
|| expanded_url.starts_with("../")
{
return Err(crate::core::AgpmError::ManifestValidationError {
reason: format!(
"Plain directory path '{url}' cannot be used as source '{name}'. \n\
Sources must be git repositories. Use one of:\n\
- Remote URL: https://github.com/owner/repo.git\n\
- Local git repo: file:///absolute/path/to/repo\n\
- Or use direct path dependencies without a source"
),
}
.into());
}
}
let mut normalized_names: std::collections::HashSet<String> =
std::collections::HashSet::new();
for (name, _) in self.all_dependencies() {
let normalized = name.to_lowercase();
if !normalized_names.insert(normalized.clone()) {
for (other_name, _) in self.all_dependencies() {
if other_name != name && other_name.to_lowercase() == normalized {
return Err(crate::core::AgpmError::ManifestValidationError {
reason: format!(
"Case conflict: '{name}' and '{other_name}' would map to the same file on case-insensitive filesystems. To ensure portability across platforms, resource names must be case-insensitively unique."
),
}
.into());
}
}
}
}
for resource_type in crate::core::ResourceType::all() {
if let Some(deps) = self.get_dependencies(*resource_type) {
for (name, dep) in deps {
let tool = match dep {
ResourceDependency::Detailed(d) => &d.tool,
ResourceDependency::Simple(_) => "claude-code", };
if self.get_tool_config(tool).is_none() {
return Err(crate::core::AgpmError::ManifestValidationError {
reason: format!(
"Unknown tool '{tool}' for dependency '{name}'.\n\
Available types: {}\n\
Configure custom types in [tools] section or use a standard type.",
self.get_tools_config()
.types
.keys()
.map(|s| format!("'{s}'"))
.collect::<Vec<_>>()
.join(", ")
),
}
.into());
}
if !self.is_resource_supported(tool, *resource_type) {
let artifact_config = self.get_tool_config(tool).unwrap();
let supported_types: Vec<String> =
artifact_config.resources.keys().map(|s| s.to_string()).collect();
let mut suggestions = Vec::new();
match resource_type {
crate::core::ResourceType::Snippet => {
suggestions.push("Snippets work best with the 'agpm' tool (shared infrastructure)".to_string());
suggestions.push(
"Add tool='agpm' to this dependency to use shared snippets"
.to_string(),
);
}
_ => {
let default_config = ToolsConfig::default();
let tools_config = self.tools.as_ref().unwrap_or(&default_config);
let resource_plural = resource_type.to_plural();
let supporting_types: Vec<String> = tools_config
.types
.iter()
.filter(|(_, config)| {
config.resources.contains_key(resource_plural)
})
.map(|(type_name, _)| format!("'{}'", type_name))
.collect();
if !supporting_types.is_empty() {
suggestions.push(format!(
"This resource type is supported by tools: {}",
supporting_types.join(", ")
));
}
}
}
let mut reason = format!(
"Resource type '{}' is not supported by tool '{}' for dependency '{}'.\n\n",
resource_type.to_plural(),
tool,
name
);
reason.push_str(&format!(
"Tool '{}' supports: {}\n\n",
tool,
supported_types.join(", ")
));
if !suggestions.is_empty() {
reason.push_str("💡 Suggestions:\n");
for suggestion in &suggestions {
reason.push_str(&format!(" • {}\n", suggestion));
}
reason.push('\n');
}
reason.push_str(
"You can fix this by:\n\
1. Changing the 'tool' field to a supported tool\n\
2. Using a different resource type\n\
3. Removing this dependency from your manifest",
);
return Err(crate::core::AgpmError::ManifestValidationError {
reason,
}
.into());
}
}
}
}
Ok(())
}
pub const fn get_dependencies(
&self,
resource_type: crate::core::ResourceType,
) -> Option<&HashMap<String, ResourceDependency>> {
use crate::core::ResourceType;
match resource_type {
ResourceType::Agent => Some(&self.agents),
ResourceType::Snippet => Some(&self.snippets),
ResourceType::Command => Some(&self.commands),
ResourceType::Script => Some(&self.scripts),
ResourceType::Hook => Some(&self.hooks),
ResourceType::McpServer => Some(&self.mcp_servers),
}
}
#[allow(deprecated)]
pub fn get_target_dir(&self, resource_type: crate::core::ResourceType) -> &str {
use crate::core::ResourceType;
match resource_type {
ResourceType::Agent => &self.target.agents,
ResourceType::Snippet => &self.target.snippets,
ResourceType::Command => &self.target.commands,
ResourceType::Script => &self.target.scripts,
ResourceType::Hook => &self.target.hooks,
ResourceType::McpServer => &self.target.mcp_servers,
}
}
pub fn get_tools_config(&self) -> &ToolsConfig {
self.tools.as_ref().unwrap_or_else(|| {
static DEFAULT: std::sync::OnceLock<ToolsConfig> = std::sync::OnceLock::new();
DEFAULT.get_or_init(ToolsConfig::default)
})
}
pub fn get_tool_config(&self, tool: &str) -> Option<&ArtifactTypeConfig> {
self.get_tools_config().types.get(tool)
}
pub fn get_artifact_resource_path(
&self,
tool: &str,
resource_type: crate::core::ResourceType,
) -> Option<std::path::PathBuf> {
let artifact_config = self.get_tool_config(tool)?;
let resource_config = artifact_config.resources.get(resource_type.to_plural())?;
resource_config.path.as_ref().map(|subdir| artifact_config.path.join(subdir))
}
pub fn is_resource_supported(
&self,
tool: &str,
resource_type: crate::core::ResourceType,
) -> bool {
self.get_tool_config(tool)
.and_then(|config| config.resources.get(resource_type.to_plural()))
.is_some()
}
#[must_use]
pub fn all_dependencies(&self) -> Vec<(&str, &ResourceDependency)> {
let mut deps = Vec::new();
for resource_type in crate::core::ResourceType::all() {
if let Some(type_deps) = self.get_dependencies(*resource_type) {
for (name, dep) in type_deps {
deps.push((name.as_str(), dep));
}
}
}
deps
}
#[must_use]
pub fn all_dependencies_with_mcp(
&self,
) -> Vec<(&str, std::borrow::Cow<'_, ResourceDependency>)> {
let mut deps = Vec::new();
for resource_type in crate::core::ResourceType::all() {
if let Some(type_deps) = self.get_dependencies(*resource_type) {
for (name, dep) in type_deps {
deps.push((name.as_str(), std::borrow::Cow::Borrowed(dep)));
}
}
}
deps
}
#[must_use]
pub fn has_dependency(&self, name: &str) -> bool {
self.agents.contains_key(name)
|| self.snippets.contains_key(name)
|| self.commands.contains_key(name)
}
#[must_use]
pub fn get_dependency(&self, name: &str) -> Option<&ResourceDependency> {
self.agents
.get(name)
.or_else(|| self.snippets.get(name))
.or_else(|| self.commands.get(name))
}
pub fn find_dependency(&self, name: &str) -> Option<&ResourceDependency> {
self.get_dependency(name)
}
pub fn add_source(&mut self, name: String, url: String) {
self.sources.insert(name, url);
}
pub fn add_dependency(&mut self, name: String, dep: ResourceDependency, is_agent: bool) {
if is_agent {
self.agents.insert(name, dep);
} else {
self.snippets.insert(name, dep);
}
}
pub fn add_typed_dependency(
&mut self,
name: String,
dep: ResourceDependency,
resource_type: crate::core::ResourceType,
) {
match resource_type {
crate::core::ResourceType::Agent => {
self.agents.insert(name, dep);
}
crate::core::ResourceType::Snippet => {
self.snippets.insert(name, dep);
}
crate::core::ResourceType::Command => {
self.commands.insert(name, dep);
}
crate::core::ResourceType::McpServer => {
panic!("Use add_mcp_server() for MCP server dependencies");
}
crate::core::ResourceType::Script => {
self.scripts.insert(name, dep);
}
crate::core::ResourceType::Hook => {
self.hooks.insert(name, dep);
}
}
}
pub fn add_mcp_server(&mut self, name: String, dependency: ResourceDependency) {
self.mcp_servers.insert(name, dependency);
}
}
impl ResourceDependency {
#[must_use]
pub fn get_source(&self) -> Option<&str> {
match self {
Self::Simple(_) => None,
Self::Detailed(d) => d.source.as_deref(),
}
}
#[must_use]
pub fn get_target(&self) -> Option<&str> {
match self {
Self::Simple(_) => None,
Self::Detailed(d) => d.target.as_deref(),
}
}
#[must_use]
pub fn get_filename(&self) -> Option<&str> {
match self {
Self::Simple(_) => None,
Self::Detailed(d) => d.filename.as_deref(),
}
}
#[must_use]
pub fn get_path(&self) -> &str {
match self {
Self::Simple(path) => path,
Self::Detailed(d) => &d.path,
}
}
#[must_use]
pub fn is_pattern(&self) -> bool {
let path = self.get_path();
path.contains('*') || path.contains('?') || path.contains('[')
}
#[must_use]
pub fn get_version(&self) -> Option<&str> {
match self {
Self::Simple(_) => None,
Self::Detailed(d) => {
d.rev.as_deref().or(d.branch.as_deref()).or(d.version.as_deref())
}
}
}
#[must_use]
pub fn is_local(&self) -> bool {
self.get_source().is_none()
}
#[must_use]
pub fn get_tool(&self) -> &str {
match self {
Self::Simple(_) => "claude-code", Self::Detailed(d) => &d.tool,
}
}
}
impl Default for Manifest {
fn default() -> Self {
Self::new()
}
}
fn expand_url(url: &str) -> Result<String> {
if url.starts_with("http://")
|| url.starts_with("https://")
|| url.starts_with("git@")
|| url.starts_with("file://")
{
return Ok(url.to_string());
}
if url.contains('/') || url.contains('\\') || url.starts_with('~') || url.contains('$') {
match crate::utils::platform::resolve_path(url) {
Ok(expanded_path) => {
let path_str = expanded_path.to_string_lossy();
if expanded_path.is_absolute() {
Ok(format!("file://{path_str}"))
} else {
Ok(format!(
"file://{}",
std::env::current_dir()?.join(expanded_path).to_string_lossy()
))
}
}
Err(_) => {
Ok(url.to_string())
}
}
} else {
Ok(url.to_string())
}
}
pub fn find_manifest() -> Result<PathBuf> {
let current = std::env::current_dir()
.context("Cannot determine current working directory. This may indicate a permission issue or corrupted filesystem")?;
find_manifest_from(current)
}
pub fn find_manifest_with_optional(explicit_path: Option<PathBuf>) -> Result<PathBuf> {
match explicit_path {
Some(path) => {
if path.exists() {
Ok(path)
} else {
Err(crate::core::AgpmError::ManifestNotFound.into())
}
}
None => find_manifest(),
}
}
pub fn find_manifest_from(mut current: PathBuf) -> Result<PathBuf> {
loop {
let manifest_path = current.join("agpm.toml");
if manifest_path.exists() {
return Ok(manifest_path);
}
if !current.pop() {
return Err(crate::core::AgpmError::ManifestNotFound.into());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_manifest_new() {
let manifest = Manifest::new();
assert!(manifest.sources.is_empty());
assert!(manifest.agents.is_empty());
assert!(manifest.snippets.is_empty());
assert!(manifest.commands.is_empty());
assert!(manifest.mcp_servers.is_empty());
}
#[test]
fn test_manifest_load_save() {
let temp = tempdir().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let mut manifest = Manifest::new();
manifest.add_source(
"official".to_string(),
"https://github.com/example-org/agpm-official.git".to_string(),
);
manifest.add_dependency(
"test-agent".to_string(),
ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("official".to_string()),
path: "agents/test.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
})),
true,
);
manifest.save(&manifest_path).unwrap();
let loaded = Manifest::load(&manifest_path).unwrap();
assert_eq!(loaded.sources.len(), 1);
assert_eq!(loaded.agents.len(), 1);
assert!(loaded.has_dependency("test-agent"));
}
#[test]
fn test_manifest_validation() {
let mut manifest = Manifest::new();
manifest.add_dependency(
"local-agent".to_string(),
ResourceDependency::Simple("../local/agent.md".to_string()),
true,
);
assert!(manifest.validate().is_ok());
manifest.add_dependency(
"remote-agent".to_string(),
ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("undefined".to_string()),
path: "agent.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
})),
true,
);
assert!(manifest.validate().is_err());
manifest
.add_source("undefined".to_string(), "https://github.com/test/repo.git".to_string());
assert!(manifest.validate().is_ok());
}
#[test]
fn test_dependency_helpers() {
let simple_dep = ResourceDependency::Simple("path/to/file.md".to_string());
assert_eq!(simple_dep.get_path(), "path/to/file.md");
assert!(simple_dep.get_source().is_none());
assert!(simple_dep.get_version().is_none());
assert!(simple_dep.is_local());
let detailed_dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("official".to_string()),
path: "agents/test.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
}));
assert_eq!(detailed_dep.get_path(), "agents/test.md");
assert_eq!(detailed_dep.get_source(), Some("official"));
assert_eq!(detailed_dep.get_version(), Some("v1.0.0"));
assert!(!detailed_dep.is_local());
}
#[test]
fn test_all_dependencies() {
let mut manifest = Manifest::new();
manifest.add_dependency(
"agent1".to_string(),
ResourceDependency::Simple("a1.md".to_string()),
true,
);
manifest.add_dependency(
"snippet1".to_string(),
ResourceDependency::Simple("s1.md".to_string()),
false,
);
let all_deps = manifest.all_dependencies();
assert_eq!(all_deps.len(), 2);
}
#[test]
fn test_source_url_validation() {
let mut manifest = Manifest::new();
manifest.add_source("http".to_string(), "http://github.com/test/repo.git".to_string());
manifest.add_source("https".to_string(), "https://github.com/test/repo.git".to_string());
manifest.add_source("ssh".to_string(), "git@github.com:test/repo.git".to_string());
assert!(manifest.validate().is_ok());
manifest.add_source("invalid".to_string(), "not-a-url".to_string());
let result = manifest.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("invalid URL"));
}
#[test]
fn test_manifest_commands() {
let mut manifest = Manifest::new();
manifest.add_typed_dependency(
"build-command".to_string(),
ResourceDependency::Simple("commands/build.md".to_string()),
crate::core::ResourceType::Command,
);
assert!(manifest.commands.contains_key("build-command"));
assert_eq!(manifest.commands.len(), 1);
assert!(manifest.has_dependency("build-command"));
let dep = manifest.get_dependency("build-command");
assert!(dep.is_some());
assert_eq!(dep.unwrap().get_path(), "commands/build.md");
}
#[test]
fn test_manifest_all_dependencies_with_commands() {
let mut manifest = Manifest::new();
manifest.add_typed_dependency(
"agent1".to_string(),
ResourceDependency::Simple("a1.md".to_string()),
crate::core::ResourceType::Agent,
);
manifest.add_typed_dependency(
"snippet1".to_string(),
ResourceDependency::Simple("s1.md".to_string()),
crate::core::ResourceType::Snippet,
);
manifest.add_typed_dependency(
"command1".to_string(),
ResourceDependency::Simple("c1.md".to_string()),
crate::core::ResourceType::Command,
);
let all_deps = manifest.all_dependencies();
assert_eq!(all_deps.len(), 3);
assert!(manifest.agents.contains_key("agent1"));
assert!(manifest.snippets.contains_key("snippet1"));
assert!(manifest.commands.contains_key("command1"));
}
#[test]
fn test_manifest_save_load_commands() {
let temp = tempdir().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let mut manifest = Manifest::new();
manifest.add_source(
"community".to_string(),
"https://github.com/example/community.git".to_string(),
);
manifest.add_typed_dependency(
"deploy".to_string(),
ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("community".to_string()),
path: "commands/deploy.md".to_string(),
version: Some("v2.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
})),
crate::core::ResourceType::Command,
);
manifest.save(&manifest_path).unwrap();
let loaded = Manifest::load(&manifest_path).unwrap();
assert_eq!(loaded.commands.len(), 1);
assert!(loaded.commands.contains_key("deploy"));
assert!(loaded.has_dependency("deploy"));
let dep = loaded.get_dependency("deploy").unwrap();
assert_eq!(dep.get_path(), "commands/deploy.md");
assert_eq!(dep.get_version(), Some("v2.0.0"));
}
#[test]
#[allow(deprecated)]
fn test_target_config_commands_dir() {
let config = TargetConfig::default();
assert_eq!(config.commands, ".claude/commands");
let mut manifest = Manifest::new();
manifest.target.commands = "custom/commands".to_string();
assert_eq!(manifest.target.commands, "custom/commands");
}
#[test]
fn test_mcp_servers() {
let mut manifest = Manifest::new();
manifest.add_mcp_server(
"test-server".to_string(),
ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("npm".to_string()),
path: "mcp-servers/test-server.json".to_string(),
version: Some("latest".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
})),
);
assert_eq!(manifest.mcp_servers.len(), 1);
assert!(manifest.mcp_servers.contains_key("test-server"));
let server = &manifest.mcp_servers["test-server"];
assert_eq!(server.get_source(), Some("npm"));
assert_eq!(server.get_path(), "mcp-servers/test-server.json");
assert_eq!(server.get_version(), Some("latest"));
}
#[test]
fn test_manifest_save_load_mcp_servers() {
let temp = tempdir().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let mut manifest = Manifest::new();
manifest.add_source("npm".to_string(), "https://registry.npmjs.org".to_string());
manifest.add_mcp_server(
"postgres".to_string(),
ResourceDependency::Simple("../local/mcp-servers/postgres.json".to_string()),
);
manifest.save(&manifest_path).unwrap();
let loaded = Manifest::load(&manifest_path).unwrap();
assert_eq!(loaded.mcp_servers.len(), 1);
assert!(loaded.mcp_servers.contains_key("postgres"));
let server = &loaded.mcp_servers["postgres"];
assert_eq!(server.get_path(), "../local/mcp-servers/postgres.json");
}
#[test]
#[allow(deprecated)]
fn test_target_config_mcp_servers_dir() {
let config = TargetConfig::default();
assert_eq!(config.mcp_servers, ".claude/agpm/mcp-servers");
let mut manifest = Manifest::new();
manifest.target.mcp_servers = "custom/mcp".to_string();
assert_eq!(manifest.target.mcp_servers, "custom/mcp");
}
#[test]
fn test_dependency_with_custom_target() {
let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("official".to_string()),
path: "agents/tool.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: Some("custom/tools".to_string()),
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
}));
assert_eq!(dep.get_target(), Some("custom/tools"));
assert_eq!(dep.get_source(), Some("official"));
assert_eq!(dep.get_path(), "agents/tool.md");
}
#[test]
fn test_dependency_without_custom_target() {
let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("official".to_string()),
path: "agents/tool.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
}));
assert!(dep.get_target().is_none());
}
#[test]
fn test_simple_dependency_no_custom_target() {
let dep = ResourceDependency::Simple("../local/file.md".to_string());
assert!(dep.get_target().is_none());
}
#[test]
fn test_save_load_dependency_with_custom_target() {
let temp = tempdir().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let mut manifest = Manifest::new();
manifest.add_source(
"official".to_string(),
"https://github.com/example/official.git".to_string(),
);
manifest.add_typed_dependency(
"special-agent".to_string(),
ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("official".to_string()),
path: "agents/special.md".to_string(),
version: Some("v1.0.0".to_string()),
target: Some("integrations/ai".to_string()),
branch: None,
rev: None,
command: None,
args: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
})),
crate::core::ResourceType::Agent,
);
manifest.save(&manifest_path).unwrap();
let loaded = Manifest::load(&manifest_path).unwrap();
assert_eq!(loaded.agents.len(), 1);
assert!(loaded.agents.contains_key("special-agent"));
let dep = loaded.get_dependency("special-agent").unwrap();
assert_eq!(dep.get_target(), Some("integrations/ai"));
assert_eq!(dep.get_path(), "agents/special.md");
}
#[test]
fn test_dependency_with_custom_filename() {
let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("official".to_string()),
path: "agents/tool.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: Some("ai-assistant.md".to_string()),
dependencies: None,
tool: "claude-code".to_string(),
}));
assert_eq!(dep.get_filename(), Some("ai-assistant.md"));
assert_eq!(dep.get_source(), Some("official"));
assert_eq!(dep.get_path(), "agents/tool.md");
}
#[test]
fn test_dependency_without_custom_filename() {
let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("official".to_string()),
path: "agents/tool.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
}));
assert!(dep.get_filename().is_none());
}
#[test]
fn test_simple_dependency_no_custom_filename() {
let dep = ResourceDependency::Simple("../local/file.md".to_string());
assert!(dep.get_filename().is_none());
}
#[test]
fn test_save_load_dependency_with_custom_filename() {
let temp = tempdir().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let mut manifest = Manifest::new();
manifest.add_source(
"official".to_string(),
"https://github.com/example/official.git".to_string(),
);
manifest.add_typed_dependency(
"my-agent".to_string(),
ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("official".to_string()),
path: "agents/complex-name.md".to_string(),
version: Some("v1.0.0".to_string()),
target: None,
filename: Some("simple-name.txt".to_string()),
branch: None,
rev: None,
command: None,
args: None,
dependencies: None,
tool: "claude-code".to_string(),
})),
crate::core::ResourceType::Agent,
);
manifest.save(&manifest_path).unwrap();
let loaded = Manifest::load(&manifest_path).unwrap();
assert_eq!(loaded.agents.len(), 1);
assert!(loaded.agents.contains_key("my-agent"));
let dep = loaded.get_dependency("my-agent").unwrap();
assert_eq!(dep.get_filename(), Some("simple-name.txt"));
assert_eq!(dep.get_path(), "agents/complex-name.md");
}
#[test]
fn test_pattern_dependency() {
let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("repo".to_string()),
path: "agents/**/*.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
}));
assert!(dep.is_pattern());
assert_eq!(dep.get_path(), "agents/**/*.md");
assert!(!dep.is_local());
}
#[test]
fn test_pattern_dependency_validation() {
let mut manifest = Manifest::new();
manifest
.sources
.insert("repo".to_string(), "https://github.com/example/repo.git".to_string());
manifest.agents.insert(
"ai-agents".to_string(),
ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("repo".to_string()),
path: "agents/ai/*.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
})),
);
assert!(manifest.validate().is_ok());
manifest.agents.insert(
"regular".to_string(),
ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("repo".to_string()),
path: "agents/test.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
})),
);
let result = manifest.validate();
assert!(result.is_ok());
}
#[test]
fn test_pattern_dependency_with_path_traversal() {
let mut manifest = Manifest::new();
manifest
.sources
.insert("repo".to_string(), "https://github.com/example/repo.git".to_string());
manifest.agents.insert(
"unsafe".to_string(),
ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("repo".to_string()),
path: "../../../etc/*.conf".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
})),
);
let result = manifest.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Invalid pattern"));
}
#[test]
fn test_dependency_with_both_target_and_filename() {
let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("official".to_string()),
path: "agents/tool.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: Some("tools/ai".to_string()),
filename: Some("assistant.markdown".to_string()),
dependencies: None,
tool: "claude-code".to_string(),
}));
assert_eq!(dep.get_target(), Some("tools/ai"));
assert_eq!(dep.get_filename(), Some("assistant.markdown"));
}
}
#[cfg(test)]
mod tool_tests {
use super::*;
#[test]
fn test_detailed_dependency_tool_parsing() {
let toml_str = r#"
[agents]
opencode-helper = { source = "test_repo", path = "agents/helper.md", version = "v1.0.0", tool = "opencode" }
"#;
let manifest: Manifest = toml::from_str(toml_str).unwrap();
let helper = manifest.agents.get("opencode-helper").unwrap();
match helper {
ResourceDependency::Detailed(d) => {
assert_eq!(d.tool, "opencode", "tool should be 'opencode'");
}
_ => panic!("Expected Detailed dependency"),
}
}
#[test]
fn test_tool_name_validation() {
let toml_with_slash = r#"
[sources]
test = "https://example.com/repo.git"
[tools."bad/name"]
path = ".claude"
[tools."bad/name".resources.agents]
path = "agents"
[agents]
test = { source = "test", path = "agents/test.md", type = "bad/name" }
"#;
let manifest: Result<Manifest, _> = toml::from_str(toml_with_slash);
assert!(manifest.is_ok(), "Manifest should parse (validation happens in validate())");
let manifest = manifest.unwrap();
let result = manifest.validate();
assert!(result.is_err(), "Validation should fail for artifact type with forward slash");
let err = result.unwrap_err();
assert!(
err.to_string().contains("cannot contain path separators"),
"Error should mention path separators, got: {}",
err
);
let toml_with_backslash = r#"
[sources]
test = "https://example.com/repo.git"
[tools."bad\\name"]
path = ".claude"
[tools."bad\\name".resources.agents]
path = "agents"
[agents]
test = { source = "test", path = "agents/test.md", type = "bad\\name" }
"#;
let manifest: Result<Manifest, _> = toml::from_str(toml_with_backslash);
assert!(manifest.is_ok(), "Manifest should parse (validation happens in validate())");
let manifest = manifest.unwrap();
let result = manifest.validate();
assert!(result.is_err(), "Validation should fail for artifact type with backslash");
let toml_with_dotdot = r#"
[sources]
test = "https://example.com/repo.git"
[tools."bad..name"]
path = ".claude"
[tools."bad..name".resources.agents]
path = "agents"
[agents]
test = { source = "test", path = "agents/test.md", type = "bad..name" }
"#;
let manifest: Result<Manifest, _> = toml::from_str(toml_with_dotdot);
assert!(manifest.is_ok(), "Manifest should parse (validation happens in validate())");
let manifest = manifest.unwrap();
let result = manifest.validate();
assert!(result.is_err(), "Validation should fail for artifact type with ..");
let err = result.unwrap_err();
assert!(
err.to_string().contains("cannot contain '..'"),
"Error should mention path traversal, got: {}",
err
);
let toml_valid = r#"
[sources]
test = "https://example.com/repo.git"
[tools."my-custom-type"]
path = ".custom"
[tools."my-custom-type".resources.agents]
path = "agents"
[agents]
test = { source = "test", path = "agents/test.md", version = "v1.0.0", tool = "my-custom-type" }
"#;
let manifest: Result<Manifest, _> = toml::from_str(toml_valid);
assert!(manifest.is_ok(), "Valid manifest should parse");
let manifest = manifest.unwrap();
let result = manifest.validate();
assert!(result.is_ok(), "Valid artifact type name should pass validation");
}
}