use super::LockedResource;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
const PRIVATE_LOCK_FILENAME: &str = "agpm.private.lock";
const PRIVATE_LOCK_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PrivateLockFile {
pub version: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub agents: Vec<LockedResource>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub snippets: Vec<LockedResource>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub commands: Vec<LockedResource>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scripts: Vec<LockedResource>,
#[serde(default, skip_serializing_if = "Vec::is_empty", rename = "mcp-servers")]
pub mcp_servers: Vec<LockedResource>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hooks: Vec<LockedResource>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skills: Vec<LockedResource>,
}
impl Default for PrivateLockFile {
fn default() -> Self {
Self::new()
}
}
impl PrivateLockFile {
pub fn new() -> Self {
Self {
version: PRIVATE_LOCK_VERSION,
agents: Vec::new(),
snippets: Vec::new(),
commands: Vec::new(),
scripts: Vec::new(),
mcp_servers: Vec::new(),
hooks: Vec::new(),
skills: Vec::new(),
}
}
pub fn load(project_dir: &Path) -> Result<Option<Self>> {
let path = project_dir.join(PRIVATE_LOCK_FILENAME);
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let mut lock: Self = toml::from_str(&content)
.with_context(|| format!("Failed to parse {}", path.display()))?;
if lock.version > PRIVATE_LOCK_VERSION {
anyhow::bail!(
"Private lockfile version {} is newer than supported version {}. \
Please upgrade AGPM.",
lock.version,
PRIVATE_LOCK_VERSION
);
}
Self::set_resource_types(&mut lock.agents, crate::core::ResourceType::Agent);
Self::set_resource_types(&mut lock.snippets, crate::core::ResourceType::Snippet);
Self::set_resource_types(&mut lock.commands, crate::core::ResourceType::Command);
Self::set_resource_types(&mut lock.scripts, crate::core::ResourceType::Script);
Self::set_resource_types(&mut lock.mcp_servers, crate::core::ResourceType::McpServer);
Self::set_resource_types(&mut lock.hooks, crate::core::ResourceType::Hook);
Self::set_resource_types(&mut lock.skills, crate::core::ResourceType::Skill);
Ok(Some(lock))
}
fn set_resource_types(
resources: &mut [LockedResource],
resource_type: crate::core::ResourceType,
) {
for resource in resources {
resource.resource_type = resource_type;
}
}
pub fn save(&self, project_dir: &Path) -> Result<()> {
let path = project_dir.join(PRIVATE_LOCK_FILENAME);
if self.is_empty() {
if path.exists() {
std::fs::remove_file(&path)
.with_context(|| format!("Failed to remove {}", path.display()))?;
}
return Ok(());
}
let content = serialize_private_lockfile(self)?;
std::fs::write(&path, content)
.with_context(|| format!("Failed to write {}", path.display()))?;
Ok(())
}
pub fn is_empty(&self) -> bool {
self.agents.is_empty()
&& self.snippets.is_empty()
&& self.commands.is_empty()
&& self.scripts.is_empty()
&& self.mcp_servers.is_empty()
&& self.hooks.is_empty()
&& self.skills.is_empty()
}
pub fn total_resources(&self) -> usize {
self.agents.len()
+ self.snippets.len()
+ self.commands.len()
+ self.scripts.len()
+ self.mcp_servers.len()
+ self.hooks.len()
+ self.skills.len()
}
pub fn all_resources(&self) -> Vec<&LockedResource> {
let mut resources: Vec<&LockedResource> = Vec::new();
resources.extend(self.agents.iter());
resources.extend(self.snippets.iter());
resources.extend(self.commands.iter());
resources.extend(self.scripts.iter());
resources.extend(self.mcp_servers.iter());
resources.extend(self.hooks.iter());
resources.extend(self.skills.iter());
resources
}
pub fn from_resources(resources: Vec<LockedResource>) -> Self {
let mut private_lock = Self::new();
for resource in resources {
match resource.resource_type {
crate::core::ResourceType::Agent => private_lock.agents.push(resource),
crate::core::ResourceType::Snippet => private_lock.snippets.push(resource),
crate::core::ResourceType::Command => private_lock.commands.push(resource),
crate::core::ResourceType::Script => private_lock.scripts.push(resource),
crate::core::ResourceType::McpServer => private_lock.mcp_servers.push(resource),
crate::core::ResourceType::Hook => private_lock.hooks.push(resource),
crate::core::ResourceType::Skill => private_lock.skills.push(resource),
}
}
private_lock
}
}
fn serialize_private_lockfile(lockfile: &PrivateLockFile) -> Result<String> {
use toml_edit::{DocumentMut, Item};
let toml_str =
toml::to_string_pretty(lockfile).context("Failed to serialize private lockfile to TOML")?;
let mut doc: DocumentMut = toml_str.parse().context("Failed to parse TOML document")?;
let resource_types =
["agents", "snippets", "commands", "scripts", "hooks", "mcp-servers", "skills"];
for resource_type in &resource_types {
if let Some(Item::ArrayOfTables(array)) = doc.get_mut(resource_type) {
for table in array.iter_mut() {
if let Some(Item::Table(patches_table)) = table.get_mut("applied_patches") {
let mut inline = toml_edit::InlineTable::new();
for (key, val) in patches_table.iter() {
if let Some(v) = val.as_value() {
inline.insert(key, v.clone());
}
}
table.insert("applied_patches", toml_edit::value(inline));
}
if let Some(Item::Table(variant_table)) = table.get_mut("variant_inputs") {
let mut inline = toml_edit::InlineTable::new();
for (key, val) in variant_table.iter() {
if let Some(v) = val.as_value() {
inline.insert(key, v.clone());
}
}
table.insert("variant_inputs", toml_edit::value(inline));
}
}
}
}
Ok(doc.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::ResourceType;
use crate::resolver::lockfile_builder::VariantInputs;
use std::collections::BTreeMap;
use tempfile::TempDir;
fn create_test_resource(name: &str, resource_type: ResourceType) -> LockedResource {
LockedResource {
name: name.to_string(),
source: Some("test-source".to_string()),
url: Some("https://github.com/test/repo.git".to_string()),
path: format!("{}/{}.md", resource_type, name),
version: Some("v1.0.0".to_string()),
resolved_commit: Some("abc123def456".to_string()),
checksum: "sha256:test123".to_string(),
context_checksum: None,
installed_at: format!(".claude/{}/private/{}.md", resource_type, name),
dependencies: Vec::new(),
resource_type,
tool: Some("claude-code".to_string()),
manifest_alias: Some(name.to_string()),
applied_patches: BTreeMap::new(),
install: None,
variant_inputs: VariantInputs::default(),
is_private: true,
approximate_token_count: None,
}
}
#[test]
fn test_new_lockfile_is_empty() {
let lock = PrivateLockFile::new();
assert!(lock.is_empty());
assert_eq!(lock.total_resources(), 0);
}
#[test]
fn test_from_resources() {
let resources = vec![
create_test_resource("agent1", ResourceType::Agent),
create_test_resource("snippet1", ResourceType::Snippet),
create_test_resource("command1", ResourceType::Command),
];
let lock = PrivateLockFile::from_resources(resources);
assert!(!lock.is_empty());
assert_eq!(lock.total_resources(), 3);
assert_eq!(lock.agents.len(), 1);
assert_eq!(lock.snippets.len(), 1);
assert_eq!(lock.commands.len(), 1);
}
#[test]
fn test_save_and_load() {
let temp_dir = TempDir::new().unwrap();
let resources = vec![create_test_resource("test-agent", ResourceType::Agent)];
let lock = PrivateLockFile::from_resources(resources);
lock.save(temp_dir.path()).unwrap();
let loaded = PrivateLockFile::load(temp_dir.path()).unwrap();
assert!(loaded.is_some());
let loaded_lock = loaded.unwrap();
assert_eq!(loaded_lock.agents.len(), 1);
assert_eq!(loaded_lock.agents[0].name, "test-agent");
assert_eq!(loaded_lock.agents[0].resource_type, ResourceType::Agent);
}
#[test]
fn test_empty_lockfile_deletes_file() {
let temp_dir = TempDir::new().unwrap();
let lock_path = temp_dir.path().join(PRIVATE_LOCK_FILENAME);
std::fs::write(&lock_path, "test").unwrap();
assert!(lock_path.exists());
let lock = PrivateLockFile::new();
lock.save(temp_dir.path()).unwrap();
assert!(!lock_path.exists());
}
#[test]
fn test_load_nonexistent_returns_none() {
let temp_dir = TempDir::new().unwrap();
let result = PrivateLockFile::load(temp_dir.path()).unwrap();
assert!(result.is_none());
}
#[test]
fn test_all_resources() {
let resources = vec![
create_test_resource("agent1", ResourceType::Agent),
create_test_resource("agent2", ResourceType::Agent),
create_test_resource("snippet1", ResourceType::Snippet),
];
let lock = PrivateLockFile::from_resources(resources);
let all = lock.all_resources();
assert_eq!(all.len(), 3);
}
#[test]
fn test_lockfile_split_by_privacy() {
use crate::lockfile::LockFile;
let mut lockfile = LockFile::new();
let public_agent = LockedResource {
name: "public-agent".to_string(),
source: Some("test".to_string()),
url: Some("https://github.com/test/repo.git".to_string()),
path: "agents/public.md".to_string(),
version: Some("v1.0.0".to_string()),
resolved_commit: Some("abc123".to_string()),
checksum: "sha256:test".to_string(),
context_checksum: None,
installed_at: ".claude/agents/agpm/public.md".to_string(),
dependencies: Vec::new(),
resource_type: ResourceType::Agent,
tool: Some("claude-code".to_string()),
manifest_alias: Some("public-agent".to_string()),
applied_patches: BTreeMap::new(),
install: None,
variant_inputs: VariantInputs::default(),
is_private: false,
approximate_token_count: None,
};
let private_agent = LockedResource {
name: "private-agent".to_string(),
source: Some("private".to_string()),
url: Some("git@github.com:me/private.git".to_string()),
path: "agents/private.md".to_string(),
version: Some("v1.0.0".to_string()),
resolved_commit: Some("def456".to_string()),
checksum: "sha256:private".to_string(),
context_checksum: None,
installed_at: ".claude/agents/agpm/private/private.md".to_string(),
dependencies: Vec::new(),
resource_type: ResourceType::Agent,
tool: Some("claude-code".to_string()),
manifest_alias: Some("private-agent".to_string()),
applied_patches: BTreeMap::new(),
install: None,
variant_inputs: VariantInputs::default(),
is_private: true,
approximate_token_count: None,
};
lockfile.agents.push(public_agent);
lockfile.agents.push(private_agent);
let (public_lock, private_lock) = lockfile.split_by_privacy();
assert_eq!(public_lock.agents.len(), 1);
assert_eq!(public_lock.agents[0].name, "public-agent");
assert!(!public_lock.agents[0].is_private);
assert_eq!(private_lock.agents.len(), 1);
assert_eq!(private_lock.agents[0].name, "private-agent");
assert!(private_lock.agents[0].is_private);
}
#[test]
fn test_lockfile_merge_private() {
use crate::lockfile::LockFile;
let mut public_lock = LockFile::new();
public_lock.agents.push(LockedResource {
name: "public-agent".to_string(),
source: Some("test".to_string()),
url: Some("https://github.com/test/repo.git".to_string()),
path: "agents/public.md".to_string(),
version: Some("v1.0.0".to_string()),
resolved_commit: Some("abc123".to_string()),
checksum: "sha256:test".to_string(),
context_checksum: None,
installed_at: ".claude/agents/agpm/public.md".to_string(),
dependencies: Vec::new(),
resource_type: ResourceType::Agent,
tool: Some("claude-code".to_string()),
manifest_alias: Some("public-agent".to_string()),
applied_patches: BTreeMap::new(),
install: None,
variant_inputs: VariantInputs::default(),
is_private: false,
approximate_token_count: None,
});
public_lock.resource_count = Some(1);
let private_lock = PrivateLockFile::from_resources(vec![create_test_resource(
"private-agent",
ResourceType::Agent,
)]);
public_lock.merge_private(&private_lock);
assert_eq!(public_lock.agents.len(), 2);
assert!(public_lock.agents.iter().any(|a| a.name == "public-agent"));
assert!(public_lock.agents.iter().any(|a| a.name == "private-agent"));
assert_eq!(public_lock.resource_count, Some(2));
}
#[test]
fn test_split_and_merge_roundtrip() {
use crate::lockfile::LockFile;
let mut original = LockFile::new();
original.agents.push(LockedResource {
name: "public".to_string(),
source: Some("test".to_string()),
url: Some("https://github.com/test/repo.git".to_string()),
path: "agents/public.md".to_string(),
version: Some("v1.0.0".to_string()),
resolved_commit: Some("abc123".to_string()),
checksum: "sha256:test".to_string(),
context_checksum: None,
installed_at: ".claude/agents/agpm/public.md".to_string(),
dependencies: Vec::new(),
resource_type: ResourceType::Agent,
tool: Some("claude-code".to_string()),
manifest_alias: Some("public".to_string()),
applied_patches: BTreeMap::new(),
install: None,
variant_inputs: VariantInputs::default(),
is_private: false,
approximate_token_count: None,
});
original.agents.push(LockedResource {
name: "private".to_string(),
source: Some("private".to_string()),
url: Some("git@github.com:me/private.git".to_string()),
path: "agents/private.md".to_string(),
version: Some("v1.0.0".to_string()),
resolved_commit: Some("def456".to_string()),
checksum: "sha256:private".to_string(),
context_checksum: None,
installed_at: ".claude/agents/agpm/private/private.md".to_string(),
dependencies: Vec::new(),
resource_type: ResourceType::Agent,
tool: Some("claude-code".to_string()),
manifest_alias: Some("private".to_string()),
applied_patches: BTreeMap::new(),
install: None,
variant_inputs: VariantInputs::default(),
is_private: true,
approximate_token_count: None,
});
let (mut public_lock, private_lock) = original.split_by_privacy();
assert_eq!(public_lock.agents.len(), 1);
assert_eq!(private_lock.agents.len(), 1);
public_lock.merge_private(&private_lock);
assert_eq!(public_lock.agents.len(), 2);
}
}