pub mod dependency_spec;
pub mod helpers;
pub mod patches;
pub mod resource_dependency;
pub mod tool_config;
#[cfg(test)]
mod manifest_flatten_tests;
#[cfg(test)]
mod manifest_hash_tests;
#[cfg(test)]
mod manifest_mutable_tests;
#[cfg(test)]
mod manifest_template_tests;
#[cfg(test)]
mod manifest_tests;
#[cfg(test)]
mod manifest_tool_tests;
mod manifest_validation;
#[cfg(test)]
mod manifest_validation_tests;
#[cfg(test)]
mod resource_dependency_tests;
#[cfg(test)]
mod tool_config_tests;
use crate::core::file_error::{FileOperation, FileResultExt};
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 helpers::{expand_url, find_manifest, find_manifest_from, find_manifest_with_optional};
pub use patches::{ManifestPatches, PatchConflict, PatchData, PatchOrigin};
pub use resource_dependency::{DetailedDependency, ResourceDependency};
pub use tool_config::{ArtifactTypeConfig, ResourceConfig, ToolsConfig, WellKnownTool};
#[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)
}
}
pub(crate) 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 mut keys: Vec<_> = table.keys().collect();
keys.sort();
let map: serde_json::Map<String, serde_json::Value> =
keys.into_iter().map(|k| (k.clone(), toml_value_to_json(&table[k]))).collect();
serde_json::Value::Object(map)
}
}
}
#[cfg(test)]
pub(crate) fn json_value_to_toml(value: &serde_json::Value) -> toml::Value {
match value {
serde_json::Value::String(s) => toml::Value::String(s.clone()),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
toml::Value::Integer(i)
} else if let Some(f) = n.as_f64() {
toml::Value::Float(f)
} else {
toml::Value::String(n.to_string())
}
}
serde_json::Value::Bool(b) => toml::Value::Boolean(*b),
serde_json::Value::Null => {
toml::Value::String(String::new())
}
serde_json::Value::Array(arr) => {
toml::Value::Array(arr.iter().map(json_value_to_toml).collect())
}
serde_json::Value::Object(obj) => {
let table: toml::map::Map<String, toml::Value> =
obj.iter().map(|(k, v)| (k.clone(), json_value_to_toml(v))).collect();
toml::Value::Table(table)
}
}
}
#[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 = "HashMap::is_empty")]
pub skills: 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>,
#[serde(skip)]
pub private_dependency_names: std::collections::HashSet<(String, String)>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_warning_threshold: Option<u64>,
#[serde(default = "default_gitignore")]
pub gitignore: bool,
}
fn default_gitignore() -> bool {
true
}
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(),
skills: HashMap::new(),
patches: ManifestPatches::new(),
project_patches: ManifestPatches::new(),
private_patches: ManifestPatches::new(),
default_tools: HashMap::new(),
project: None,
manifest_dir: None,
private_dependency_names: std::collections::HashSet::new(),
token_warning_threshold: None,
gitignore: true,
}
}
pub fn load(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path).with_file_context(
FileOperation::Read,
path,
"reading manifest file",
"manifest_module",
)?;
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)?;
for (name, url) in private_manifest.sources {
manifest.sources.insert(name, url);
}
let mut private_names = std::collections::HashSet::new();
for (name, dep) in private_manifest.agents {
private_names.insert(("agents".to_string(), name.clone()));
manifest.agents.insert(name, dep);
}
for (name, dep) in private_manifest.snippets {
private_names.insert(("snippets".to_string(), name.clone()));
manifest.snippets.insert(name, dep);
}
for (name, dep) in private_manifest.commands {
private_names.insert(("commands".to_string(), name.clone()));
manifest.commands.insert(name, dep);
}
for (name, dep) in private_manifest.scripts {
private_names.insert(("scripts".to_string(), name.clone()));
manifest.scripts.insert(name, dep);
}
for (name, dep) in private_manifest.hooks {
private_names.insert(("hooks".to_string(), name.clone()));
manifest.hooks.insert(name, dep);
}
for (name, dep) in private_manifest.mcp_servers {
private_names.insert(("mcp-servers".to_string(), name.clone()));
manifest.mcp_servers.insert(name, dep);
}
manifest.private_dependency_names = private_names;
manifest.private_patches = private_manifest.patches.clone();
let (merged_patches, conflicts) =
manifest.patches.merge_with(&private_manifest.patches);
manifest.patches = merged_patches;
manifest.validate().with_context(|| {
format!(
"Validation failed after merging private manifest: {}",
private_path.display()
)
})?;
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_file_context(
FileOperation::Read,
path,
"reading private manifest file",
"manifest_module",
)?;
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 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.tools.is_some() {
anyhow::bail!(
"Private manifest file ({}) cannot contain [tools] section. \
Tool configuration must be defined in the project manifest (agpm.toml).",
path.display()
);
}
manifest.apply_tool_defaults();
manifest.manifest_dir = Some(
path.parent()
.ok_or_else(|| anyhow::anyhow!("Private manifest path has no parent directory"))?
.to_path_buf(),
);
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",
crate::core::ResourceType::Skill => "skills",
};
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_file_context(
FileOperation::Write,
path,
"writing manifest file",
"manifest_module",
)?;
Ok(())
}
pub 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),
ResourceType::Skill => Some(&self.skills),
}
}
#[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),
ResourceType::Skill => Some(&mut self.skills),
}
}
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| {
let mut result = artifact_config.path.clone();
for component in subdir.split('/') {
result = result.join(component);
}
result
})
}
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) {
let mut sorted_deps: Vec<_> = type_deps.iter().collect();
sorted_deps.sort_by_key(|(name, _)| name.as_str());
for (name, dep) in sorted_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) {
let mut sorted_deps: Vec<_> = type_deps.iter().collect();
sorted_deps.sort_by_key(|(name, _)| name.as_str());
for (name, dep) in sorted_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) {
let mut sorted_deps: Vec<_> = type_deps.iter().collect();
sorted_deps.sort_by_key(|(name, _)| name.as_str());
for (name, dep) in sorted_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;
}
}
let dep_with_tool = if dep.get_tool().is_none() {
tracing::debug!(
"Setting default tool '{}' for dependency '{}' (type: {:?})",
tool,
name,
resource_type
);
let mut dep_owned = dep.clone();
dep_owned.set_tool(Some(tool_string.clone()));
std::borrow::Cow::Owned(dep_owned)
} else {
std::borrow::Cow::Borrowed(dep)
};
deps.push((name.as_str(), dep_with_tool, *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);
}
crate::core::ResourceType::Skill => {
self.skills.insert(name, dep);
}
}
}
#[must_use]
pub fn get_resources(
&self,
resource_type: &crate::core::ResourceType,
) -> &HashMap<String, ResourceDependency> {
use crate::core::ResourceType;
match resource_type {
ResourceType::Agent => &self.agents,
ResourceType::Snippet => &self.snippets,
ResourceType::Command => &self.commands,
ResourceType::Script => &self.scripts,
ResourceType::Hook => &self.hooks,
ResourceType::McpServer => &self.mcp_servers,
ResourceType::Skill => &self.skills,
}
}
#[must_use]
pub fn all_resources(&self) -> Vec<(crate::core::ResourceType, &str, &ResourceDependency)> {
use crate::core::ResourceType;
let mut resources = Vec::new();
for resource_type in ResourceType::all() {
let type_resources = self.get_resources(resource_type);
for (name, dep) in type_resources {
resources.push((*resource_type, name.as_str(), dep));
}
}
resources
}
pub fn add_mcp_server(&mut self, name: String, dependency: ResourceDependency) {
self.mcp_servers.insert(name, dependency);
}
#[must_use]
pub fn compute_dependency_hash(&self) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
let mut sources: Vec<_> = self.sources.iter().collect();
sources.sort_by_key(|(k, _)| *k);
for (name, url) in sources {
hasher.update(b"source:");
hasher.update(name.as_bytes());
hasher.update(b"=");
hasher.update(url.as_bytes());
hasher.update(b"\n");
}
for resource_type in crate::core::ResourceType::all() {
let resources = self.get_resources(resource_type);
let mut sorted_resources: Vec<_> = resources.iter().collect();
sorted_resources.sort_by_key(|(k, _)| *k);
for (name, dep) in sorted_resources {
hasher.update(format!("{}:", resource_type).as_bytes());
hasher.update(name.as_bytes());
hasher.update(b"=");
match serde_json::to_value(dep).and_then(|v| serde_json::to_string(&v)) {
Ok(json) => hasher.update(json.as_bytes()),
Err(e) => {
tracing::warn!(
"Failed to serialize dependency '{}' for hashing: {}. Using name fallback.",
name,
e
);
hasher.update(b"<serialization_failed:");
hasher.update(name.as_bytes());
hasher.update(b">");
}
}
hasher.update(b"\n");
}
}
if !self.patches.is_empty() {
match serde_json::to_value(&self.patches).and_then(|v| serde_json::to_string(&v)) {
Ok(json) => {
hasher.update(b"patches=");
hasher.update(json.as_bytes());
hasher.update(b"\n");
}
Err(e) => {
tracing::warn!(
"Failed to serialize patches for hashing: {}. Using fallback.",
e
);
hasher.update(b"patches=<serialization_failed>\n");
}
}
}
if let Some(tools) = &self.tools {
match serde_json::to_value(tools).and_then(|v| serde_json::to_string(&v)) {
Ok(json) => {
hasher.update(b"tools=");
hasher.update(json.as_bytes());
hasher.update(b"\n");
}
Err(e) => {
tracing::warn!("Failed to serialize tools for hashing: {}. Using fallback.", e);
hasher.update(b"tools=<serialization_failed>\n");
}
}
}
let result = hasher.finalize();
format!("sha256:{}", hex::encode(result))
}
#[must_use]
pub fn has_mutable_dependencies(&self) -> bool {
self.all_resources().into_iter().any(|(_, _, dep)| dep.is_mutable())
}
#[must_use]
pub fn is_private_dependency(&self, resource_type: &str, name: &str) -> bool {
let plural_type = match resource_type {
"agent" => "agents",
"snippet" => "snippets",
"command" => "commands",
"script" => "scripts",
"hook" => "hooks",
"mcp-server" => "mcp-servers",
other => other,
};
self.private_dependency_names.contains(&(plural_type.to_string(), name.to_string()))
}
}
impl Default for Manifest {
fn default() -> Self {
Self::new()
}
}