pub mod dependency_spec;
pub mod patches;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub use dependency_spec::{DependencyMetadata, DependencySpec};
pub use patches::{ManifestPatches, PatchConflict, PatchData, PatchOrigin};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProjectConfig(toml::map::Map<String, toml::Value>);
impl ProjectConfig {
pub fn to_json_value(&self) -> serde_json::Value {
toml_value_to_json(&toml::Value::Table(self.0.clone()))
}
}
impl From<toml::map::Map<String, toml::Value>> for ProjectConfig {
fn from(map: toml::map::Map<String, toml::Value>) -> Self {
Self(map)
}
}
fn toml_value_to_json(value: &toml::Value) -> serde_json::Value {
match value {
toml::Value::String(s) => serde_json::Value::String(s.clone()),
toml::Value::Integer(i) => serde_json::Value::Number((*i).into()),
toml::Value::Float(f) => serde_json::Number::from_f64(*f)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null),
toml::Value::Boolean(b) => serde_json::Value::Bool(*b),
toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
toml::Value::Array(arr) => {
serde_json::Value::Array(arr.iter().map(toml_value_to_json).collect())
}
toml::Value::Table(table) => {
let map: serde_json::Map<String, serde_json::Value> =
table.iter().map(|(k, v)| (k.clone(), toml_value_to_json(v))).collect();
serde_json::Value::Object(map)
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub sources: HashMap<String, String>,
#[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>,
#[serde(default, skip_serializing_if = "ManifestPatches::is_empty", rename = "patch")]
pub patches: ManifestPatches,
#[serde(skip)]
pub project_patches: ManifestPatches,
#[serde(skip)]
pub private_patches: ManifestPatches,
#[serde(default, skip_serializing_if = "HashMap::is_empty", rename = "default-tools")]
pub default_tools: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub project: Option<ProjectConfig>,
#[serde(skip)]
pub manifest_dir: Option<std::path::PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ResourceConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "merge-target")]
pub merge_target: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub flatten: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArtifactTypeConfig {
pub path: PathBuf,
pub resources: HashMap<String, ResourceConfig>,
#[serde(default = "default_tool_enabled")]
pub enabled: bool,
}
const fn default_tool_enabled() -> bool {
true
}
#[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()),
merge_target: None,
flatten: Some(true), },
);
claude_resources.insert(
ResourceType::Snippet.to_plural().to_string(),
ResourceConfig {
path: Some("snippets".to_string()),
merge_target: None,
flatten: Some(false), },
);
claude_resources.insert(
ResourceType::Command.to_plural().to_string(),
ResourceConfig {
path: Some("commands".to_string()),
merge_target: None,
flatten: Some(true), },
);
claude_resources.insert(
ResourceType::Script.to_plural().to_string(),
ResourceConfig {
path: Some("scripts".to_string()),
merge_target: None,
flatten: Some(false), },
);
claude_resources.insert(
ResourceType::Hook.to_plural().to_string(),
ResourceConfig {
path: None, merge_target: Some(".claude/settings.local.json".to_string()),
flatten: None, },
);
claude_resources.insert(
ResourceType::McpServer.to_plural().to_string(),
ResourceConfig {
path: None, merge_target: Some(".mcp.json".to_string()),
flatten: None, },
);
types.insert(
"claude-code".to_string(),
ArtifactTypeConfig {
path: PathBuf::from(".claude"),
resources: claude_resources,
enabled: true,
},
);
let mut opencode_resources = HashMap::new();
opencode_resources.insert(
ResourceType::Agent.to_plural().to_string(),
ResourceConfig {
path: Some("agent".to_string()), merge_target: None,
flatten: Some(true), },
);
opencode_resources.insert(
ResourceType::Command.to_plural().to_string(),
ResourceConfig {
path: Some("command".to_string()), merge_target: None,
flatten: Some(true), },
);
opencode_resources.insert(
ResourceType::McpServer.to_plural().to_string(),
ResourceConfig {
path: None, merge_target: Some(".opencode/opencode.json".to_string()),
flatten: None, },
);
types.insert(
"opencode".to_string(),
ArtifactTypeConfig {
path: PathBuf::from(".opencode"),
resources: opencode_resources,
enabled: true,
},
);
let mut agpm_resources = HashMap::new();
agpm_resources.insert(
ResourceType::Snippet.to_plural().to_string(),
ResourceConfig {
path: Some("snippets".to_string()),
merge_target: None,
flatten: Some(false), },
);
types.insert(
"agpm".to_string(),
ArtifactTypeConfig {
path: PathBuf::from(".agpm"),
resources: agpm_resources,
enabled: true,
},
);
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 {
".agpm/snippets".to_string()
}
fn default_commands_dir() -> String {
".claude/commands".to_string()
}
fn default_mcp_servers_dir() -> String {
".mcp.json".to_string()
}
fn default_scripts_dir() -> String {
".claude/scripts".to_string()
}
fn default_hooks_dir() -> String {
".claude/settings.local.json".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(skip_serializing_if = "Option::is_none")]
pub tool: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub flatten: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub install: Option<bool>,
}
impl Manifest {
#[must_use]
#[allow(deprecated)]
pub fn new() -> Self {
Self {
sources: HashMap::new(),
tools: None,
agents: HashMap::new(),
snippets: HashMap::new(),
commands: HashMap::new(),
mcp_servers: HashMap::new(),
scripts: HashMap::new(),
hooks: HashMap::new(),
patches: ManifestPatches::new(),
project_patches: ManifestPatches::new(),
private_patches: ManifestPatches::new(),
default_tools: HashMap::new(),
project: None,
manifest_dir: None,
}
}
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.manifest_dir = Some(
path.parent()
.ok_or_else(|| anyhow::anyhow!("Manifest path has no parent directory"))?
.to_path_buf(),
);
manifest.validate()?;
Ok(manifest)
}
pub fn load_with_private(path: &Path) -> Result<(Self, Vec<PatchConflict>)> {
let mut manifest = Self::load(path)?;
manifest.project_patches = manifest.patches.clone();
let private_path = if let Some(parent) = path.parent() {
parent.join("agpm.private.toml")
} else {
PathBuf::from("agpm.private.toml")
};
if private_path.exists() {
let private_manifest = Self::load_private(&private_path)?;
manifest.private_patches = private_manifest.patches.clone();
let (merged_patches, conflicts) =
manifest.patches.merge_with(&private_manifest.patches);
manifest.patches = merged_patches;
Ok((manifest, conflicts))
} else {
manifest.private_patches = ManifestPatches::new();
Ok((manifest, Vec::new()))
}
}
fn load_private(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path).with_context(|| {
format!(
"Cannot read private manifest file: {}\n\n\
Possible causes:\n\
- File doesn't exist or has been moved\n\
- Permission denied (check file ownership)\n\
- File is locked by another process",
path.display()
)
})?;
let manifest: Self = toml::from_str(&content)
.map_err(|e| crate::core::AgpmError::ManifestParseError {
file: path.display().to_string(),
reason: e.to_string(),
})
.with_context(|| {
format!(
"Invalid TOML syntax in private 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()
)
})?;
if !manifest.sources.is_empty()
|| manifest.tools.is_some()
|| !manifest.agents.is_empty()
|| !manifest.snippets.is_empty()
|| !manifest.commands.is_empty()
|| !manifest.mcp_servers.is_empty()
|| !manifest.scripts.is_empty()
|| !manifest.hooks.is_empty()
{
anyhow::bail!(
"Private manifest file ({}) can only contain [patch] sections, not sources, tools, or dependencies",
path.display()
);
}
Ok(manifest)
}
#[must_use]
pub fn get_default_tool(&self, resource_type: crate::core::ResourceType) -> String {
let resource_name = match resource_type {
crate::core::ResourceType::Agent => "agents",
crate::core::ResourceType::Snippet => "snippets",
crate::core::ResourceType::Command => "commands",
crate::core::ResourceType::Script => "scripts",
crate::core::ResourceType::Hook => "hooks",
crate::core::ResourceType::McpServer => "mcp-servers",
};
if let Some(tool) = self.default_tools.get(resource_name) {
return tool.clone();
}
resource_type.default_tool().to_string()
}
fn apply_tool_defaults(&mut self) {
for resource_type in [
crate::core::ResourceType::Snippet,
crate::core::ResourceType::Agent,
crate::core::ResourceType::Command,
crate::core::ResourceType::Script,
crate::core::ResourceType::Hook,
crate::core::ResourceType::McpServer,
] {
let default_tool = self.get_default_tool(resource_type);
if let Some(deps) = self.get_dependencies_mut(resource_type) {
for dependency in deps.values_mut() {
if let ResourceDependency::Detailed(details) = dependency {
if details.tool.is_none() {
details.tool = Some(default_tool.clone());
}
}
}
}
}
}
pub fn save(&self, path: &Path) -> Result<()> {
let mut doc = toml_edit::ser::to_document(self)
.with_context(|| "Failed to serialize manifest data to TOML format")?;
for (_key, value) in doc.iter_mut() {
if let Some(inline_table) = value.as_inline_table() {
let table = inline_table.clone().into_table();
*value = toml_edit::Item::Table(table);
}
}
let content = doc.to_string();
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_string = dep
.get_tool()
.map(|s| s.to_string())
.unwrap_or_else(|| self.get_default_tool(*resource_type));
let tool = tool_string.as_str();
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 resource_plural = resource_type.to_plural();
let is_malformed = artifact_config.resources.contains_key(resource_plural);
let supported_types: Vec<String> = artifact_config
.resources
.iter()
.filter(|(_, res_config)| {
res_config.path.is_some() || res_config.merge_target.is_some()
})
.map(|(s, _)| s.to_string())
.collect();
let mut suggestions = Vec::new();
if is_malformed {
suggestions.push(format!(
"Resource type '{}' is configured for tool '{}' but missing required 'path' or 'merge_target' field",
resource_plural, tool
));
match resource_type {
crate::core::ResourceType::Hook => {
suggestions.push("For hooks, add: merge_target = '.claude/settings.local.json'".to_string());
}
crate::core::ResourceType::McpServer => {
suggestions.push(
"For MCP servers, add: merge_target = '.mcp.json'"
.to_string(),
);
}
_ => {
suggestions.push(format!(
"For {}, add: path = '{}'",
resource_plural, resource_plural
));
}
}
} else {
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 supporting_types: Vec<String> = tools_config
.types
.iter()
.filter(|(_, config)| {
config.resources.contains_key(resource_plural)
&& config
.resources
.get(resource_plural)
.map(|res| {
res.path.is_some()
|| res.merge_target.is_some()
})
.unwrap_or(false)
})
.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 = if is_malformed {
format!(
"Resource type '{}' is improperly configured for tool '{}' for dependency '{}'.\n\n",
resource_plural, tool, name
)
} else {
format!(
"Resource type '{}' is not supported by tool '{}' for dependency '{}'.\n\n",
resource_plural, tool, name
)
};
reason.push_str(&format!(
"Tool '{}' properly 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());
}
}
}
}
self.validate_patches()?;
Ok(())
}
fn validate_patches(&self) -> Result<()> {
use crate::core::ResourceType;
let check_patch_aliases = |resource_type: ResourceType,
patches: &HashMap<String, PatchData>|
-> Result<()> {
let deps = self.get_dependencies(resource_type);
for alias in patches.keys() {
let exists = if let Some(deps) = deps {
deps.contains_key(alias)
} else {
false
};
if !exists {
return Err(crate::core::AgpmError::ManifestValidationError {
reason: format!(
"Patch references unknown alias '{alias}' in [patch.{}] section.\n\
The alias must be defined in [{}] section of agpm.toml.\n\
To patch a transitive dependency, first add it explicitly to your manifest.",
resource_type.to_plural(),
resource_type.to_plural()
),
}
.into());
}
}
Ok(())
};
check_patch_aliases(ResourceType::Agent, &self.patches.agents)?;
check_patch_aliases(ResourceType::Snippet, &self.patches.snippets)?;
check_patch_aliases(ResourceType::Command, &self.patches.commands)?;
check_patch_aliases(ResourceType::Script, &self.patches.scripts)?;
check_patch_aliases(ResourceType::McpServer, &self.patches.mcp_servers)?;
check_patch_aliases(ResourceType::Hook, &self.patches.hooks)?;
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),
}
}
#[must_use]
pub fn get_dependencies_mut(
&mut self,
resource_type: crate::core::ResourceType,
) -> Option<&mut HashMap<String, ResourceDependency>> {
use crate::core::ResourceType;
match resource_type {
ResourceType::Agent => Some(&mut self.agents),
ResourceType::Snippet => Some(&mut self.snippets),
ResourceType::Command => Some(&mut self.commands),
ResourceType::Script => Some(&mut self.scripts),
ResourceType::Hook => Some(&mut self.hooks),
ResourceType::McpServer => Some(&mut self.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 get_merge_target(
&self,
tool: &str,
resource_type: crate::core::ResourceType,
) -> Option<PathBuf> {
let artifact_config = self.get_tool_config(tool)?;
let resource_config = artifact_config.resources.get(resource_type.to_plural())?;
resource_config.merge_target.as_ref().map(PathBuf::from)
}
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()))
.map(|res_config| res_config.path.is_some() || res_config.merge_target.is_some())
.unwrap_or(false)
}
#[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
}
pub fn all_dependencies_with_types(
&self,
) -> Vec<(&str, std::borrow::Cow<'_, ResourceDependency>, crate::core::ResourceType)> {
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 {
let tool_string = dep
.get_tool()
.map(|s| s.to_string())
.unwrap_or_else(|| self.get_default_tool(*resource_type));
let tool = tool_string.as_str();
if let Some(tool_config) = self.get_tools_config().types.get(tool) {
if !tool_config.enabled {
tracing::debug!(
"Skipping dependency '{}' for disabled tool '{}'",
name,
tool
);
continue;
}
}
deps.push((name.as_str(), std::borrow::Cow::Borrowed(dep), *resource_type));
}
}
}
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_tool(&self) -> Option<&str> {
match self {
Self::Detailed(d) => d.tool.as_deref(),
Self::Simple(_) => None,
}
}
#[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_flatten(&self) -> Option<bool> {
match self {
Self::Simple(_) => None,
Self::Detailed(d) => d.flatten,
}
}
#[must_use]
pub fn get_install(&self) -> Option<bool> {
match self {
Self::Simple(_) => None,
Self::Detailed(d) => d.install,
}
}
#[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()
}
}
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: Some("claude-code".to_string()),
flatten: None,
install: None,
})),
true,
);
manifest.save(&manifest_path).unwrap();
let loaded = Manifest::load(&manifest_path).unwrap();
assert_eq!(loaded.sources.len(), 1);
assert_eq!(loaded.agents.len(), 1);
assert!(loaded.has_dependency("test-agent"));
}
#[test]
fn test_manifest_validation() {
let mut manifest = Manifest::new();
manifest.add_dependency(
"local-agent".to_string(),
ResourceDependency::Simple("../local/agent.md".to_string()),
true,
);
assert!(manifest.validate().is_ok());
manifest.add_dependency(
"remote-agent".to_string(),
ResourceDependency::Detailed(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: Some("claude-code".to_string()),
flatten: None,
install: None,
})),
true,
);
assert!(manifest.validate().is_err());
manifest
.add_source("undefined".to_string(), "https://github.com/test/repo.git".to_string());
assert!(manifest.validate().is_ok());
}
#[test]
fn test_dependency_helpers() {
let simple_dep = ResourceDependency::Simple("path/to/file.md".to_string());
assert_eq!(simple_dep.get_path(), "path/to/file.md");
assert!(simple_dep.get_source().is_none());
assert!(simple_dep.get_version().is_none());
assert!(simple_dep.is_local());
let detailed_dep = ResourceDependency::Detailed(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: Some("claude-code".to_string()),
flatten: None,
install: None,
}));
assert_eq!(detailed_dep.get_path(), "agents/test.md");
assert_eq!(detailed_dep.get_source(), Some("official"));
assert_eq!(detailed_dep.get_version(), Some("v1.0.0"));
assert!(!detailed_dep.is_local());
}
#[test]
fn test_all_dependencies() {
let mut manifest = Manifest::new();
manifest.add_dependency(
"agent1".to_string(),
ResourceDependency::Simple("a1.md".to_string()),
true,
);
manifest.add_dependency(
"snippet1".to_string(),
ResourceDependency::Simple("s1.md".to_string()),
false,
);
let all_deps = manifest.all_dependencies();
assert_eq!(all_deps.len(), 2);
}
#[test]
fn test_source_url_validation() {
let mut manifest = Manifest::new();
manifest.add_source("http".to_string(), "http://github.com/test/repo.git".to_string());
manifest.add_source("https".to_string(), "https://github.com/test/repo.git".to_string());
manifest.add_source("ssh".to_string(), "git@github.com:test/repo.git".to_string());
assert!(manifest.validate().is_ok());
manifest.add_source("invalid".to_string(), "not-a-url".to_string());
let result = manifest.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("invalid URL"));
}
#[test]
fn test_manifest_commands() {
let mut manifest = Manifest::new();
manifest.add_typed_dependency(
"build-command".to_string(),
ResourceDependency::Simple("commands/build.md".to_string()),
crate::core::ResourceType::Command,
);
assert!(manifest.commands.contains_key("build-command"));
assert_eq!(manifest.commands.len(), 1);
assert!(manifest.has_dependency("build-command"));
let dep = manifest.get_dependency("build-command");
assert!(dep.is_some());
assert_eq!(dep.unwrap().get_path(), "commands/build.md");
}
#[test]
fn test_manifest_all_dependencies_with_commands() {
let mut manifest = Manifest::new();
manifest.add_typed_dependency(
"agent1".to_string(),
ResourceDependency::Simple("a1.md".to_string()),
crate::core::ResourceType::Agent,
);
manifest.add_typed_dependency(
"snippet1".to_string(),
ResourceDependency::Simple("s1.md".to_string()),
crate::core::ResourceType::Snippet,
);
manifest.add_typed_dependency(
"command1".to_string(),
ResourceDependency::Simple("c1.md".to_string()),
crate::core::ResourceType::Command,
);
let all_deps = manifest.all_dependencies();
assert_eq!(all_deps.len(), 3);
assert!(manifest.agents.contains_key("agent1"));
assert!(manifest.snippets.contains_key("snippet1"));
assert!(manifest.commands.contains_key("command1"));
}
#[test]
fn test_manifest_save_load_commands() {
let temp = tempdir().unwrap();
let manifest_path = temp.path().join("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: Some("claude-code".to_string()),
flatten: None,
install: None,
})),
crate::core::ResourceType::Command,
);
manifest.save(&manifest_path).unwrap();
let loaded = Manifest::load(&manifest_path).unwrap();
assert_eq!(loaded.commands.len(), 1);
assert!(loaded.commands.contains_key("deploy"));
assert!(loaded.has_dependency("deploy"));
let dep = loaded.get_dependency("deploy").unwrap();
assert_eq!(dep.get_path(), "commands/deploy.md");
assert_eq!(dep.get_version(), Some("v2.0.0"));
}
#[test]
fn test_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: Some("claude-code".to_string()),
flatten: None,
install: None,
})),
);
assert_eq!(manifest.mcp_servers.len(), 1);
assert!(manifest.mcp_servers.contains_key("test-server"));
let server = &manifest.mcp_servers["test-server"];
assert_eq!(server.get_source(), Some("npm"));
assert_eq!(server.get_path(), "mcp-servers/test-server.json");
assert_eq!(server.get_version(), Some("latest"));
}
#[test]
fn test_manifest_save_load_mcp_servers() {
let temp = tempdir().unwrap();
let manifest_path = temp.path().join("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]
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: Some("claude-code".to_string()),
flatten: None,
install: None,
}));
assert_eq!(dep.get_target(), Some("custom/tools"));
assert_eq!(dep.get_source(), Some("official"));
assert_eq!(dep.get_path(), "agents/tool.md");
}
#[test]
fn test_dependency_without_custom_target() {
let dep = ResourceDependency::Detailed(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: Some("claude-code".to_string()),
flatten: None,
install: None,
}));
assert!(dep.get_target().is_none());
}
#[test]
fn test_simple_dependency_no_custom_target() {
let dep = ResourceDependency::Simple("../local/file.md".to_string());
assert!(dep.get_target().is_none());
}
#[test]
fn test_save_load_dependency_with_custom_target() {
let temp = tempdir().unwrap();
let manifest_path = temp.path().join("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: Some("claude-code".to_string()),
flatten: None,
install: None,
})),
crate::core::ResourceType::Agent,
);
manifest.save(&manifest_path).unwrap();
let loaded = Manifest::load(&manifest_path).unwrap();
assert_eq!(loaded.agents.len(), 1);
assert!(loaded.agents.contains_key("special-agent"));
let dep = loaded.get_dependency("special-agent").unwrap();
assert_eq!(dep.get_target(), Some("integrations/ai"));
assert_eq!(dep.get_path(), "agents/special.md");
}
#[test]
fn test_dependency_with_custom_filename() {
let dep = ResourceDependency::Detailed(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: Some("claude-code".to_string()),
flatten: None,
install: None,
}));
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: Some("claude-code".to_string()),
flatten: None,
install: None,
}));
assert!(dep.get_filename().is_none());
}
#[test]
fn test_simple_dependency_no_custom_filename() {
let dep = ResourceDependency::Simple("../local/file.md".to_string());
assert!(dep.get_filename().is_none());
}
#[test]
fn test_save_load_dependency_with_custom_filename() {
let temp = tempdir().unwrap();
let manifest_path = temp.path().join("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: Some("claude-code".to_string()),
flatten: None,
install: None,
})),
crate::core::ResourceType::Agent,
);
manifest.save(&manifest_path).unwrap();
let loaded = Manifest::load(&manifest_path).unwrap();
assert_eq!(loaded.agents.len(), 1);
assert!(loaded.agents.contains_key("my-agent"));
let dep = loaded.get_dependency("my-agent").unwrap();
assert_eq!(dep.get_filename(), Some("simple-name.txt"));
assert_eq!(dep.get_path(), "agents/complex-name.md");
}
#[test]
fn test_pattern_dependency() {
let dep = ResourceDependency::Detailed(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: Some("claude-code".to_string()),
flatten: None,
install: None,
}));
assert!(dep.is_pattern());
assert_eq!(dep.get_path(), "agents/**/*.md");
assert!(!dep.is_local());
}
#[test]
fn test_pattern_dependency_validation() {
let mut manifest = Manifest::new();
manifest
.sources
.insert("repo".to_string(), "https://github.com/example/repo.git".to_string());
manifest.agents.insert(
"ai-agents".to_string(),
ResourceDependency::Detailed(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: Some("claude-code".to_string()),
flatten: None,
install: None,
})),
);
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: Some("claude-code".to_string()),
flatten: None,
install: None,
})),
);
let result = manifest.validate();
assert!(result.is_ok());
}
#[test]
fn test_pattern_dependency_with_path_traversal() {
let mut manifest = Manifest::new();
manifest
.sources
.insert("repo".to_string(), "https://github.com/example/repo.git".to_string());
manifest.agents.insert(
"unsafe".to_string(),
ResourceDependency::Detailed(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: Some("claude-code".to_string()),
flatten: None,
install: None,
})),
);
let result = manifest.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Invalid pattern"));
}
#[test]
fn test_dependency_with_both_target_and_filename() {
let dep = ResourceDependency::Detailed(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: Some("claude-code".to_string()),
flatten: None,
install: None,
}));
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, Some("opencode".to_string()), "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");
}
#[test]
fn test_disabled_tools_filter_dependencies() {
let toml = r#"
[sources]
test = "https://example.com/repo.git"
[tools.claude-code]
path = ".claude"
resources = { agents = { path = "agents" } }
[tools.opencode]
enabled = false
path = ".opencode"
resources = { agents = { path = "agent" } }
[agents]
claude-agent = { source = "test", path = "agents/claude.md", version = "v1.0.0" }
opencode-agent = { source = "test", path = "agents/opencode.md", version = "v1.0.0", tool = "opencode" }
"#;
let manifest: Manifest = toml::from_str(toml).expect("Failed to parse manifest");
let deps = manifest.all_dependencies_with_types();
assert_eq!(deps.len(), 1, "Should only have 1 dependency (OpenCode is disabled)");
assert_eq!(deps[0].0, "claude-agent", "Should be the claude-agent");
}
#[test]
fn test_enabled_tools_include_dependencies() {
let toml = r#"
[sources]
test = "https://example.com/repo.git"
[tools.claude-code]
enabled = true
path = ".claude"
resources = { agents = { path = "agents" } }
[tools.opencode]
enabled = true
path = ".opencode"
resources = { agents = { path = "agent" } }
[agents]
claude-agent = { source = "test", path = "agents/claude.md", version = "v1.0.0" }
opencode-agent = { source = "test", path = "agents/opencode.md", version = "v1.0.0", tool = "opencode" }
"#;
let manifest: Manifest = toml::from_str(toml).expect("Failed to parse manifest");
let deps = manifest.all_dependencies_with_types();
assert_eq!(deps.len(), 2, "Should have 2 dependencies (both tools enabled)");
let dep_names: Vec<&str> = deps.iter().map(|(name, _, _)| *name).collect();
assert!(dep_names.contains(&"claude-agent"));
assert!(dep_names.contains(&"opencode-agent"));
}
#[test]
fn test_default_enabled_true() {
let toml = r#"
[sources]
test = "https://example.com/repo.git"
[tools.claude-code]
path = ".claude"
resources = { agents = { path = "agents" } }
[agents]
claude-agent = { source = "test", path = "agents/claude.md", version = "v1.0.0" }
"#;
let manifest: Manifest = toml::from_str(toml).expect("Failed to parse manifest");
let tool_config = manifest.get_tools_config();
let claude_config = tool_config.types.get("claude-code");
assert!(claude_config.is_some());
assert!(claude_config.unwrap().enabled, "Should be enabled by default");
let deps = manifest.all_dependencies_with_types();
assert_eq!(deps.len(), 1, "Should have 1 dependency (enabled by default)");
}
#[test]
fn test_default_tools_parsing() {
let toml = r#"
[default-tools]
snippets = "claude-code"
agents = "opencode"
[sources]
test = "https://example.com/repo.git"
"#;
let manifest: Manifest = toml::from_str(toml).expect("Failed to parse manifest");
assert_eq!(manifest.default_tools.len(), 2);
assert_eq!(manifest.default_tools.get("snippets"), Some(&"claude-code".to_string()));
assert_eq!(manifest.default_tools.get("agents"), Some(&"opencode".to_string()));
}
#[test]
fn test_get_default_tool_with_config() {
let mut manifest = Manifest::new();
manifest.default_tools.insert("snippets".to_string(), "claude-code".to_string());
manifest.default_tools.insert("agents".to_string(), "opencode".to_string());
assert_eq!(manifest.get_default_tool(crate::core::ResourceType::Snippet), "claude-code");
assert_eq!(manifest.get_default_tool(crate::core::ResourceType::Agent), "opencode");
assert_eq!(manifest.get_default_tool(crate::core::ResourceType::Command), "claude-code");
assert_eq!(manifest.get_default_tool(crate::core::ResourceType::Script), "claude-code");
}
#[test]
fn test_get_default_tool_without_config() {
let manifest = Manifest::new();
assert_eq!(manifest.get_default_tool(crate::core::ResourceType::Snippet), "agpm");
assert_eq!(manifest.get_default_tool(crate::core::ResourceType::Agent), "claude-code");
assert_eq!(manifest.get_default_tool(crate::core::ResourceType::Command), "claude-code");
assert_eq!(manifest.get_default_tool(crate::core::ResourceType::Script), "claude-code");
assert_eq!(manifest.get_default_tool(crate::core::ResourceType::Hook), "claude-code");
assert_eq!(manifest.get_default_tool(crate::core::ResourceType::McpServer), "claude-code");
}
#[test]
fn test_apply_tool_defaults_with_custom_config() {
use tempfile::tempdir;
let toml = r#"
[default-tools]
snippets = "claude-code"
[sources]
test = "https://example.com/repo.git"
[snippets]
example = { source = "test", path = "snippets/example.md", version = "v1.0.0" }
"#;
let temp = tempdir().unwrap();
let manifest_path = temp.path().join("agpm.toml");
std::fs::write(&manifest_path, toml).unwrap();
let manifest = Manifest::load(&manifest_path).expect("Failed to load manifest");
let snippet = manifest.snippets.get("example").unwrap();
match snippet {
ResourceDependency::Detailed(d) => {
assert_eq!(d.tool, Some("claude-code".to_string()));
}
_ => panic!("Expected detailed dependency"),
}
}
#[test]
fn test_apply_tool_defaults_without_custom_config() {
use tempfile::tempdir;
let toml = r#"
[sources]
test = "https://example.com/repo.git"
[snippets]
example = { source = "test", path = "snippets/example.md", version = "v1.0.0" }
[agents]
example = { source = "test", path = "agents/example.md", version = "v1.0.0" }
"#;
let temp = tempdir().unwrap();
let manifest_path = temp.path().join("agpm.toml");
std::fs::write(&manifest_path, toml).unwrap();
let manifest = Manifest::load(&manifest_path).expect("Failed to load manifest");
let snippet = manifest.snippets.get("example").unwrap();
match snippet {
ResourceDependency::Detailed(d) => {
assert_eq!(d.tool, Some("agpm".to_string()));
}
_ => panic!("Expected detailed dependency"),
}
let agent = manifest.agents.get("example").unwrap();
match agent {
ResourceDependency::Detailed(d) => {
assert_eq!(d.tool, Some("claude-code".to_string()));
}
_ => panic!("Expected detailed dependency"),
}
}
#[test]
fn test_default_tools_serialization() {
let mut manifest = Manifest::new();
manifest.add_source("test".to_string(), "https://example.com/repo.git".to_string());
manifest.default_tools.insert("snippets".to_string(), "claude-code".to_string());
let toml = toml::to_string(&manifest).expect("Failed to serialize");
assert!(toml.contains("[default-tools]"));
assert!(toml.contains("snippets = \"claude-code\""));
}
#[test]
fn test_default_tools_empty_not_serialized() {
let manifest = Manifest::new();
let toml = toml::to_string(&manifest).expect("Failed to serialize");
assert!(!toml.contains("[default-tools]"));
}
#[test]
fn test_merge_target_parsing() {
let toml = r#"
[sources]
test = "https://example.com/repo.git"
[tools.custom-tool]
path = ".custom"
enabled = true
[tools.custom-tool.resources.hooks]
merge-target = ".custom/hooks.json"
[tools.custom-tool.resources.mcp-servers]
merge-target = ".custom/mcp.json"
"#;
let manifest: Manifest = toml::from_str(toml).expect("Failed to parse manifest");
let tools = manifest.get_tools_config();
let custom_tool = tools.types.get("custom-tool").expect("custom-tool should exist");
let hooks_config = custom_tool.resources.get("hooks").expect("hooks config should exist");
assert_eq!(hooks_config.merge_target, Some(".custom/hooks.json".to_string()));
assert_eq!(hooks_config.path, None);
let mcp_config =
custom_tool.resources.get("mcp-servers").expect("mcp-servers config should exist");
assert_eq!(mcp_config.merge_target, Some(".custom/mcp.json".to_string()));
assert_eq!(mcp_config.path, None);
}
#[test]
fn test_get_merge_target() {
let manifest = Manifest::new();
let hook_target = manifest.get_merge_target("claude-code", crate::core::ResourceType::Hook);
assert_eq!(hook_target, Some(PathBuf::from(".claude/settings.local.json")));
let mcp_target =
manifest.get_merge_target("claude-code", crate::core::ResourceType::McpServer);
assert_eq!(mcp_target, Some(PathBuf::from(".mcp.json")));
let opencode_mcp =
manifest.get_merge_target("opencode", crate::core::ResourceType::McpServer);
assert_eq!(opencode_mcp, Some(PathBuf::from(".opencode/opencode.json")));
let agent_target =
manifest.get_merge_target("claude-code", crate::core::ResourceType::Agent);
assert_eq!(agent_target, None);
let invalid = manifest.get_merge_target("nonexistent", crate::core::ResourceType::Hook);
assert_eq!(invalid, None);
}
#[test]
fn test_is_resource_supported_with_merge_target() {
let manifest = Manifest::new();
assert!(manifest.is_resource_supported("claude-code", crate::core::ResourceType::Hook));
assert!(
manifest.is_resource_supported("claude-code", crate::core::ResourceType::McpServer)
);
assert!(manifest.is_resource_supported("opencode", crate::core::ResourceType::McpServer));
assert!(manifest.is_resource_supported("claude-code", crate::core::ResourceType::Agent));
assert!(!manifest.is_resource_supported("opencode", crate::core::ResourceType::Hook));
assert!(!manifest.is_resource_supported("opencode", crate::core::ResourceType::Script));
}
#[test]
fn test_merge_target_serialization() {
use tempfile::tempdir;
let toml = r#"
[sources]
test = "https://example.com/repo.git"
[tools.custom-tool]
path = ".custom"
enabled = true
[tools.custom-tool.resources.hooks]
merge-target = ".custom/hooks.json"
"#;
let temp = tempdir().unwrap();
let manifest_path = temp.path().join("agpm.toml");
std::fs::write(&manifest_path, toml).unwrap();
let manifest = Manifest::load(&manifest_path).expect("Failed to load");
let output_path = temp.path().join("output.toml");
manifest.save(&output_path).expect("Failed to save");
let output_toml = std::fs::read_to_string(&output_path).expect("Failed to read output");
assert!(output_toml.contains("merge-target"));
assert!(output_toml.contains(".custom/hooks.json"));
}
#[test]
fn test_merge_target_not_serialized_when_none() {
let config = ResourceConfig {
path: Some("test".to_string()),
merge_target: None,
flatten: None,
};
let config_toml = toml::to_string(&config).expect("Failed to serialize config");
assert!(!config_toml.contains("merge-target"));
}
}
#[cfg(test)]
mod flatten_tests {
use super::*;
#[test]
fn test_parse_flatten_field() {
let toml = r#"
[sources]
test = "file:///test.git"
[agents]
with-flatten-false = { source = "test", path = "agents/test.md", version = "v1.0.0", flatten = false }
with-flatten-true = { source = "test", path = "agents/test2.md", version = "v1.0.0", flatten = true }
without-flatten = { source = "test", path = "agents/test3.md", version = "v1.0.0" }
"#;
let manifest: Manifest = toml::from_str(toml).unwrap();
let agents = &manifest.agents;
let dep1 = agents.get("with-flatten-false").expect("with-flatten-false not found");
eprintln!("with-flatten-false: {:?}", dep1.get_flatten());
assert_eq!(dep1.get_flatten(), Some(false), "flatten=false should parse as Some(false)");
let dep2 = agents.get("with-flatten-true").expect("with-flatten-true not found");
eprintln!("with-flatten-true: {:?}", dep2.get_flatten());
assert_eq!(dep2.get_flatten(), Some(true), "flatten=true should parse as Some(true)");
let dep3 = agents.get("without-flatten").expect("without-flatten not found");
eprintln!("without-flatten: {:?}", dep3.get_flatten());
assert_eq!(dep3.get_flatten(), None, "missing flatten should parse as None");
}
}
#[cfg(test)]
mod validation_tests {
use super::*;
#[test]
fn test_malformed_hooks_configuration() {
let toml = r#"
[tools]
[tools.claude-code]
path = ".claude"
[tools.claude-code.resources]
agents = { path = "agents", flatten = true }
snippets = { path = "snippets", flatten = false }
commands = { path = "commands", flatten = true }
scripts = { path = "scripts", flatten = false }
hooks = { } # Malformed - no path or merge_target
[sources]
test = "https://github.com/example/test.git"
[hooks]
test-hook = { source = "test", path = "hooks/test.json", version = "v1.0.0" }
"#;
let manifest: Manifest = toml::from_str(toml).unwrap();
let result = manifest.validate();
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("improperly configured"));
assert!(error_msg.contains("missing required 'path' or 'merge_target' field"));
assert!(error_msg.contains("merge_target = '.claude/settings.local.json'"));
}
#[test]
fn test_missing_hooks_configuration() {
let toml = r#"
[tools]
[tools.claude-code]
path = ".claude"
[tools.claude-code.resources]
agents = { path = "agents", flatten = true }
snippets = { path = "snippets", flatten = false }
commands = { path = "commands", flatten = true }
scripts = { path = "scripts", flatten = false }
# hooks completely missing
[sources]
test = "https://github.com/example/test.git"
[hooks]
test-hook = { source = "test", path = "hooks/test.json", version = "v1.0.0" }
"#;
let manifest: Manifest = toml::from_str(toml).unwrap();
let result = manifest.validate();
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("not supported"));
assert!(!error_msg.contains("improperly configured"));
assert!(!error_msg.contains("missing required"));
}
#[test]
fn test_properly_configured_hooks() {
let toml = r#"
[sources]
test = "https://github.com/example/test.git"
[hooks]
test-hook = { source = "test", path = "hooks/test.json", version = "v1.0.0" }
"#;
let manifest: Manifest = toml::from_str(toml).unwrap();
let result = manifest.validate();
assert!(result.is_ok()); }
#[test]
fn test_hooks_with_only_path_no_merge_target() {
let toml = r#"
[tools]
[tools.claude-code]
path = ".claude"
[tools.claude-code.resources]
agents = { path = "agents", flatten = true }
hooks = { path = "hooks" } # Invalid - hooks need merge_target, not path
[sources]
test = "https://github.com/example/test.git"
[hooks]
test-hook = { source = "test", path = "hooks/test.json", version = "v1.0.0" }
"#;
let manifest: Manifest = toml::from_str(toml).unwrap();
let result = manifest.validate();
match result {
Ok(_) => {
println!("Validation unexpectedly passed");
println!(
"Current validation allows hooks with 'path' - this might be intended behavior"
);
}
Err(e) => {
println!("Validation failed as expected: {}", e);
let error_msg = e.to_string();
assert!(error_msg.contains("improperly configured"));
assert!(error_msg.contains("merge_target"));
assert!(error_msg.contains(".claude/settings.local.json"));
assert!(!error_msg.contains("not supported")); }
}
}
#[test]
fn test_hooks_with_both_path_and_merge_target() {
let toml = r#"
[tools]
[tools.claude-code]
path = ".claude"
[tools.claude-code.resources]
agents = { path = "agents", flatten = true }
hooks = { path = "hooks", merge-target = ".claude/settings.local.json" } # Both fields - should be OK
[sources]
test = "https://github.com/example/test.git"
[hooks]
test-hook = { source = "test", path = "hooks/test.json", version = "v1.0.0" }
"#;
let manifest: Manifest = toml::from_str(toml).unwrap();
let result = manifest.validate();
assert!(result.is_ok());
}
#[test]
fn test_mcp_servers_configuration_validation() {
let toml = r#"
[tools]
[tools.claude-code]
path = ".claude"
[tools.claude-code.resources]
agents = { path = "agents", flatten = true }
mcp-servers = { } # Malformed - no merge_target
[sources]
test = "https://github.com/example/test.git"
[mcp-servers]
test-server = { source = "test", path = "mcp/test.json", version = "v1.0.0" }
"#;
let manifest: Manifest = toml::from_str(toml).unwrap();
let result = manifest.validate();
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("improperly configured"));
assert!(error_msg.contains("mcp-servers"));
assert!(error_msg.contains("merge_target"));
assert!(error_msg.contains(".mcp.json"));
}
#[test]
fn test_snippets_with_merge_target_instead_of_path() {
let toml = r#"
[tools]
[tools.claude-code]
path = ".claude"
[tools.claude-code.resources]
snippets = { merge-target = ".claude/snippets.json" } # Actually valid - merge_target is allowed
[sources]
test = "https://github.com/example/test.git"
[snippets]
test-snippet = { source = "test", path = "snippets/test.md", version = "v1.0.0", tool = "claude-code" }
"#;
let manifest: Manifest = toml::from_str(toml).unwrap();
let result = manifest.validate();
assert!(result.is_ok());
}
}