1use super::LockedResource;
65use anyhow::{Context, Result};
66use serde::{Deserialize, Serialize};
67use std::path::Path;
68
69const PRIVATE_LOCK_FILENAME: &str = "agpm.private.lock";
70const PRIVATE_LOCK_VERSION: u32 = 1;
71
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
81pub struct PrivateLockFile {
82 pub version: u32,
84
85 #[serde(default, skip_serializing_if = "Vec::is_empty")]
87 pub agents: Vec<LockedResource>,
88
89 #[serde(default, skip_serializing_if = "Vec::is_empty")]
91 pub snippets: Vec<LockedResource>,
92
93 #[serde(default, skip_serializing_if = "Vec::is_empty")]
95 pub commands: Vec<LockedResource>,
96
97 #[serde(default, skip_serializing_if = "Vec::is_empty")]
99 pub scripts: Vec<LockedResource>,
100
101 #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "mcp-servers")]
103 pub mcp_servers: Vec<LockedResource>,
104
105 #[serde(default, skip_serializing_if = "Vec::is_empty")]
107 pub hooks: Vec<LockedResource>,
108
109 #[serde(default, skip_serializing_if = "Vec::is_empty")]
111 pub skills: Vec<LockedResource>,
112}
113
114impl Default for PrivateLockFile {
115 fn default() -> Self {
116 Self::new()
117 }
118}
119
120impl PrivateLockFile {
121 pub fn new() -> Self {
123 Self {
124 version: PRIVATE_LOCK_VERSION,
125 agents: Vec::new(),
126 snippets: Vec::new(),
127 commands: Vec::new(),
128 scripts: Vec::new(),
129 mcp_servers: Vec::new(),
130 hooks: Vec::new(),
131 skills: Vec::new(),
132 }
133 }
134
135 pub fn load(project_dir: &Path) -> Result<Option<Self>> {
153 let path = project_dir.join(PRIVATE_LOCK_FILENAME);
154 if !path.exists() {
155 return Ok(None);
156 }
157
158 let content = std::fs::read_to_string(&path)
159 .with_context(|| format!("Failed to read {}", path.display()))?;
160
161 let mut lock: Self = toml::from_str(&content)
162 .with_context(|| format!("Failed to parse {}", path.display()))?;
163
164 if lock.version > PRIVATE_LOCK_VERSION {
166 anyhow::bail!(
167 "Private lockfile version {} is newer than supported version {}. \
168 Please upgrade AGPM.",
169 lock.version,
170 PRIVATE_LOCK_VERSION
171 );
172 }
173
174 Self::set_resource_types(&mut lock.agents, crate::core::ResourceType::Agent);
176 Self::set_resource_types(&mut lock.snippets, crate::core::ResourceType::Snippet);
177 Self::set_resource_types(&mut lock.commands, crate::core::ResourceType::Command);
178 Self::set_resource_types(&mut lock.scripts, crate::core::ResourceType::Script);
179 Self::set_resource_types(&mut lock.mcp_servers, crate::core::ResourceType::McpServer);
180 Self::set_resource_types(&mut lock.hooks, crate::core::ResourceType::Hook);
181 Self::set_resource_types(&mut lock.skills, crate::core::ResourceType::Skill);
182
183 Ok(Some(lock))
184 }
185
186 fn set_resource_types(
188 resources: &mut [LockedResource],
189 resource_type: crate::core::ResourceType,
190 ) {
191 for resource in resources {
192 resource.resource_type = resource_type;
193 }
194 }
195
196 pub fn save(&self, project_dir: &Path) -> Result<()> {
211 let path = project_dir.join(PRIVATE_LOCK_FILENAME);
212
213 if self.is_empty() {
215 if path.exists() {
216 std::fs::remove_file(&path)
217 .with_context(|| format!("Failed to remove {}", path.display()))?;
218 }
219 return Ok(());
220 }
221
222 let content = serialize_private_lockfile(self)?;
223
224 std::fs::write(&path, content)
225 .with_context(|| format!("Failed to write {}", path.display()))?;
226
227 Ok(())
228 }
229
230 pub fn is_empty(&self) -> bool {
232 self.agents.is_empty()
233 && self.snippets.is_empty()
234 && self.commands.is_empty()
235 && self.scripts.is_empty()
236 && self.mcp_servers.is_empty()
237 && self.hooks.is_empty()
238 && self.skills.is_empty()
239 }
240
241 pub fn total_resources(&self) -> usize {
243 self.agents.len()
244 + self.snippets.len()
245 + self.commands.len()
246 + self.scripts.len()
247 + self.mcp_servers.len()
248 + self.hooks.len()
249 + self.skills.len()
250 }
251
252 pub fn all_resources(&self) -> Vec<&LockedResource> {
254 let mut resources: Vec<&LockedResource> = Vec::new();
255 resources.extend(self.agents.iter());
256 resources.extend(self.snippets.iter());
257 resources.extend(self.commands.iter());
258 resources.extend(self.scripts.iter());
259 resources.extend(self.mcp_servers.iter());
260 resources.extend(self.hooks.iter());
261 resources.extend(self.skills.iter());
262 resources
263 }
264
265 pub fn from_resources(resources: Vec<LockedResource>) -> Self {
269 let mut private_lock = Self::new();
270
271 for resource in resources {
272 match resource.resource_type {
273 crate::core::ResourceType::Agent => private_lock.agents.push(resource),
274 crate::core::ResourceType::Snippet => private_lock.snippets.push(resource),
275 crate::core::ResourceType::Command => private_lock.commands.push(resource),
276 crate::core::ResourceType::Script => private_lock.scripts.push(resource),
277 crate::core::ResourceType::McpServer => private_lock.mcp_servers.push(resource),
278 crate::core::ResourceType::Hook => private_lock.hooks.push(resource),
279 crate::core::ResourceType::Skill => private_lock.skills.push(resource),
280 }
281 }
282
283 private_lock
284 }
285}
286
287fn serialize_private_lockfile(lockfile: &PrivateLockFile) -> Result<String> {
291 use toml_edit::{DocumentMut, Item};
292
293 let toml_str =
295 toml::to_string_pretty(lockfile).context("Failed to serialize private lockfile to TOML")?;
296 let mut doc: DocumentMut = toml_str.parse().context("Failed to parse TOML document")?;
297
298 let resource_types =
300 ["agents", "snippets", "commands", "scripts", "hooks", "mcp-servers", "skills"];
301
302 for resource_type in &resource_types {
303 if let Some(Item::ArrayOfTables(array)) = doc.get_mut(resource_type) {
304 for table in array.iter_mut() {
305 if let Some(Item::Table(patches_table)) = table.get_mut("applied_patches") {
307 let mut inline = toml_edit::InlineTable::new();
308 for (key, val) in patches_table.iter() {
309 if let Some(v) = val.as_value() {
310 inline.insert(key, v.clone());
311 }
312 }
313 table.insert("applied_patches", toml_edit::value(inline));
314 }
315
316 if let Some(Item::Table(variant_table)) = table.get_mut("variant_inputs") {
318 let mut inline = toml_edit::InlineTable::new();
319 for (key, val) in variant_table.iter() {
320 if let Some(v) = val.as_value() {
321 inline.insert(key, v.clone());
322 }
323 }
324 table.insert("variant_inputs", toml_edit::value(inline));
325 }
326 }
327 }
328 }
329
330 Ok(doc.to_string())
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use crate::core::ResourceType;
337 use crate::resolver::lockfile_builder::VariantInputs;
338 use std::collections::BTreeMap;
339 use tempfile::TempDir;
340
341 fn create_test_resource(name: &str, resource_type: ResourceType) -> LockedResource {
342 LockedResource {
343 name: name.to_string(),
344 source: Some("test-source".to_string()),
345 url: Some("https://github.com/test/repo.git".to_string()),
346 path: format!("{}/{}.md", resource_type, name),
347 version: Some("v1.0.0".to_string()),
348 resolved_commit: Some("abc123def456".to_string()),
349 checksum: "sha256:test123".to_string(),
350 context_checksum: None,
351 installed_at: format!(".claude/{}/private/{}.md", resource_type, name),
352 dependencies: Vec::new(),
353 resource_type,
354 tool: Some("claude-code".to_string()),
355 manifest_alias: Some(name.to_string()),
356 applied_patches: BTreeMap::new(),
357 install: None,
358 variant_inputs: VariantInputs::default(),
359 is_private: true,
360 approximate_token_count: None,
361 }
362 }
363
364 #[test]
365 fn test_new_lockfile_is_empty() {
366 let lock = PrivateLockFile::new();
367 assert!(lock.is_empty());
368 assert_eq!(lock.total_resources(), 0);
369 }
370
371 #[test]
372 fn test_from_resources() {
373 let resources = vec![
374 create_test_resource("agent1", ResourceType::Agent),
375 create_test_resource("snippet1", ResourceType::Snippet),
376 create_test_resource("command1", ResourceType::Command),
377 ];
378
379 let lock = PrivateLockFile::from_resources(resources);
380
381 assert!(!lock.is_empty());
382 assert_eq!(lock.total_resources(), 3);
383 assert_eq!(lock.agents.len(), 1);
384 assert_eq!(lock.snippets.len(), 1);
385 assert_eq!(lock.commands.len(), 1);
386 }
387
388 #[test]
389 fn test_save_and_load() {
390 let temp_dir = TempDir::new().unwrap();
391 let resources = vec![create_test_resource("test-agent", ResourceType::Agent)];
392 let lock = PrivateLockFile::from_resources(resources);
393
394 lock.save(temp_dir.path()).unwrap();
396
397 let loaded = PrivateLockFile::load(temp_dir.path()).unwrap();
399 assert!(loaded.is_some());
400 let loaded_lock = loaded.unwrap();
401 assert_eq!(loaded_lock.agents.len(), 1);
402 assert_eq!(loaded_lock.agents[0].name, "test-agent");
403 assert_eq!(loaded_lock.agents[0].resource_type, ResourceType::Agent);
404 }
405
406 #[test]
407 fn test_empty_lockfile_deletes_file() {
408 let temp_dir = TempDir::new().unwrap();
409 let lock_path = temp_dir.path().join(PRIVATE_LOCK_FILENAME);
410
411 std::fs::write(&lock_path, "test").unwrap();
413 assert!(lock_path.exists());
414
415 let lock = PrivateLockFile::new();
417 lock.save(temp_dir.path()).unwrap();
418 assert!(!lock_path.exists());
419 }
420
421 #[test]
422 fn test_load_nonexistent_returns_none() {
423 let temp_dir = TempDir::new().unwrap();
424 let result = PrivateLockFile::load(temp_dir.path()).unwrap();
425 assert!(result.is_none());
426 }
427
428 #[test]
429 fn test_all_resources() {
430 let resources = vec![
431 create_test_resource("agent1", ResourceType::Agent),
432 create_test_resource("agent2", ResourceType::Agent),
433 create_test_resource("snippet1", ResourceType::Snippet),
434 ];
435
436 let lock = PrivateLockFile::from_resources(resources);
437 let all = lock.all_resources();
438
439 assert_eq!(all.len(), 3);
440 }
441
442 #[test]
443 fn test_lockfile_split_by_privacy() {
444 use crate::lockfile::LockFile;
445
446 let mut lockfile = LockFile::new();
448
449 let public_agent = LockedResource {
451 name: "public-agent".to_string(),
452 source: Some("test".to_string()),
453 url: Some("https://github.com/test/repo.git".to_string()),
454 path: "agents/public.md".to_string(),
455 version: Some("v1.0.0".to_string()),
456 resolved_commit: Some("abc123".to_string()),
457 checksum: "sha256:test".to_string(),
458 context_checksum: None,
459 installed_at: ".claude/agents/agpm/public.md".to_string(),
460 dependencies: Vec::new(),
461 resource_type: ResourceType::Agent,
462 tool: Some("claude-code".to_string()),
463 manifest_alias: Some("public-agent".to_string()),
464 applied_patches: BTreeMap::new(),
465 install: None,
466 variant_inputs: VariantInputs::default(),
467 is_private: false,
468 approximate_token_count: None,
469 };
470
471 let private_agent = LockedResource {
473 name: "private-agent".to_string(),
474 source: Some("private".to_string()),
475 url: Some("git@github.com:me/private.git".to_string()),
476 path: "agents/private.md".to_string(),
477 version: Some("v1.0.0".to_string()),
478 resolved_commit: Some("def456".to_string()),
479 checksum: "sha256:private".to_string(),
480 context_checksum: None,
481 installed_at: ".claude/agents/agpm/private/private.md".to_string(),
482 dependencies: Vec::new(),
483 resource_type: ResourceType::Agent,
484 tool: Some("claude-code".to_string()),
485 manifest_alias: Some("private-agent".to_string()),
486 applied_patches: BTreeMap::new(),
487 install: None,
488 variant_inputs: VariantInputs::default(),
489 is_private: true,
490 approximate_token_count: None,
491 };
492
493 lockfile.agents.push(public_agent);
494 lockfile.agents.push(private_agent);
495
496 let (public_lock, private_lock) = lockfile.split_by_privacy();
498
499 assert_eq!(public_lock.agents.len(), 1);
501 assert_eq!(public_lock.agents[0].name, "public-agent");
502 assert!(!public_lock.agents[0].is_private);
503
504 assert_eq!(private_lock.agents.len(), 1);
506 assert_eq!(private_lock.agents[0].name, "private-agent");
507 assert!(private_lock.agents[0].is_private);
508 }
509
510 #[test]
511 fn test_lockfile_merge_private() {
512 use crate::lockfile::LockFile;
513
514 let mut public_lock = LockFile::new();
516 public_lock.agents.push(LockedResource {
517 name: "public-agent".to_string(),
518 source: Some("test".to_string()),
519 url: Some("https://github.com/test/repo.git".to_string()),
520 path: "agents/public.md".to_string(),
521 version: Some("v1.0.0".to_string()),
522 resolved_commit: Some("abc123".to_string()),
523 checksum: "sha256:test".to_string(),
524 context_checksum: None,
525 installed_at: ".claude/agents/agpm/public.md".to_string(),
526 dependencies: Vec::new(),
527 resource_type: ResourceType::Agent,
528 tool: Some("claude-code".to_string()),
529 manifest_alias: Some("public-agent".to_string()),
530 applied_patches: BTreeMap::new(),
531 install: None,
532 variant_inputs: VariantInputs::default(),
533 is_private: false,
534 approximate_token_count: None,
535 });
536 public_lock.resource_count = Some(1);
537
538 let private_lock = PrivateLockFile::from_resources(vec![create_test_resource(
540 "private-agent",
541 ResourceType::Agent,
542 )]);
543
544 public_lock.merge_private(&private_lock);
546
547 assert_eq!(public_lock.agents.len(), 2);
549 assert!(public_lock.agents.iter().any(|a| a.name == "public-agent"));
550 assert!(public_lock.agents.iter().any(|a| a.name == "private-agent"));
551
552 assert_eq!(public_lock.resource_count, Some(2));
554 }
555
556 #[test]
557 fn test_split_and_merge_roundtrip() {
558 use crate::lockfile::LockFile;
559
560 let mut original = LockFile::new();
562 original.agents.push(LockedResource {
563 name: "public".to_string(),
564 source: Some("test".to_string()),
565 url: Some("https://github.com/test/repo.git".to_string()),
566 path: "agents/public.md".to_string(),
567 version: Some("v1.0.0".to_string()),
568 resolved_commit: Some("abc123".to_string()),
569 checksum: "sha256:test".to_string(),
570 context_checksum: None,
571 installed_at: ".claude/agents/agpm/public.md".to_string(),
572 dependencies: Vec::new(),
573 resource_type: ResourceType::Agent,
574 tool: Some("claude-code".to_string()),
575 manifest_alias: Some("public".to_string()),
576 applied_patches: BTreeMap::new(),
577 install: None,
578 variant_inputs: VariantInputs::default(),
579 is_private: false,
580 approximate_token_count: None,
581 });
582 original.agents.push(LockedResource {
583 name: "private".to_string(),
584 source: Some("private".to_string()),
585 url: Some("git@github.com:me/private.git".to_string()),
586 path: "agents/private.md".to_string(),
587 version: Some("v1.0.0".to_string()),
588 resolved_commit: Some("def456".to_string()),
589 checksum: "sha256:private".to_string(),
590 context_checksum: None,
591 installed_at: ".claude/agents/agpm/private/private.md".to_string(),
592 dependencies: Vec::new(),
593 resource_type: ResourceType::Agent,
594 tool: Some("claude-code".to_string()),
595 manifest_alias: Some("private".to_string()),
596 applied_patches: BTreeMap::new(),
597 install: None,
598 variant_inputs: VariantInputs::default(),
599 is_private: true,
600 approximate_token_count: None,
601 });
602
603 let (mut public_lock, private_lock) = original.split_by_privacy();
605
606 assert_eq!(public_lock.agents.len(), 1);
608 assert_eq!(private_lock.agents.len(), 1);
609
610 public_lock.merge_private(&private_lock);
612
613 assert_eq!(public_lock.agents.len(), 2);
615 }
616}