agpm_cli/lockfile/
private_lock.rs

1//! Private lockfile management for user-level dependencies and patches.
2//!
3//! The private lockfile (`agpm.private.lock`) tracks:
4//! 1. **Private dependencies**: Full `LockedResource` entries from `agpm.private.toml`
5//! 2. **Private patches**: Patches from `agpm.private.toml` applied to project dependencies
6//!
7//! This separation allows team members to have different private configurations without
8//! causing lockfile conflicts in the shared `agpm.lock`.
9//!
10//! # Structure
11//!
12//! The private lockfile uses the same array-based format as `agpm.lock`:
13//!
14//! ```toml
15//! version = 1
16//!
17//! # Full private dependency entries (is_private = true)
18//! [[agents]]
19//! name = "my-private-agent"
20//! source = "private-repo"
21//! path = "agents/private.md"
22//! checksum = "sha256:..."
23//! installed_at = ".claude/agents/private/my-private-agent.md"
24//! is_private = true
25//! ```
26//!
27//! # Usage
28//!
29//! ## Splitting a lockfile
30//!
31//! After dependency resolution, split the combined lockfile into public and private parts:
32//!
33//! ```rust,no_run
34//! use agpm_cli::lockfile::{LockFile, PrivateLockFile};
35//! use std::path::Path;
36//!
37//! let combined_lockfile = LockFile::new();
38//! // ... resolve dependencies ...
39//!
40//! // Split into public and private parts
41//! let (public_lock, private_lock) = combined_lockfile.split_by_privacy();
42//!
43//! // Save each to appropriate file
44//! public_lock.save(Path::new("agpm.lock"))?;
45//! private_lock.save(Path::new("."))?;
46//! # Ok::<(), anyhow::Error>(())
47//! ```
48//!
49//! ## Loading and merging
50//!
51//! When loading, merge the private lockfile back into the main lockfile:
52//!
53//! ```rust,no_run
54//! use agpm_cli::lockfile::{LockFile, PrivateLockFile};
55//! use std::path::Path;
56//!
57//! let mut lockfile = LockFile::load(Path::new("agpm.lock"))?;
58//! if let Some(private_lock) = PrivateLockFile::load(Path::new("."))? {
59//!     lockfile.merge_private(&private_lock);
60//! }
61//! # Ok::<(), anyhow::Error>(())
62//! ```
63
64use 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/// Private lockfile tracking user-level dependencies.
73///
74/// This file is gitignored and contains full `LockedResource` entries for
75/// dependencies that came from `agpm.private.toml`. It works alongside
76/// `agpm.lock` to provide full reproducibility while keeping team lockfiles
77/// deterministic.
78///
79/// Uses the same array-based format as `agpm.lock` for consistency.
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
81pub struct PrivateLockFile {
82    /// Lockfile format version
83    pub version: u32,
84
85    /// Private agents
86    #[serde(default, skip_serializing_if = "Vec::is_empty")]
87    pub agents: Vec<LockedResource>,
88
89    /// Private snippets
90    #[serde(default, skip_serializing_if = "Vec::is_empty")]
91    pub snippets: Vec<LockedResource>,
92
93    /// Private commands
94    #[serde(default, skip_serializing_if = "Vec::is_empty")]
95    pub commands: Vec<LockedResource>,
96
97    /// Private scripts
98    #[serde(default, skip_serializing_if = "Vec::is_empty")]
99    pub scripts: Vec<LockedResource>,
100
101    /// Private MCP servers
102    #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "mcp-servers")]
103    pub mcp_servers: Vec<LockedResource>,
104
105    /// Private hooks
106    #[serde(default, skip_serializing_if = "Vec::is_empty")]
107    pub hooks: Vec<LockedResource>,
108
109    /// Private skills
110    #[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    /// Create a new empty private lockfile.
122    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    /// Load private lockfile from disk.
136    ///
137    /// Returns `Ok(None)` if the file doesn't exist (no private resources).
138    ///
139    /// # Example
140    ///
141    /// ```rust,no_run
142    /// use agpm_cli::lockfile::private_lock::PrivateLockFile;
143    /// use std::path::Path;
144    ///
145    /// let project_dir = Path::new(".");
146    /// match PrivateLockFile::load(project_dir)? {
147    ///     Some(lock) => println!("Loaded {} private resources", lock.total_resources()),
148    ///     None => println!("No private lockfile found"),
149    /// }
150    /// # Ok::<(), anyhow::Error>(())
151    /// ```
152    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        // Validate version
165        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        // Set resource types after deserialization (not stored in TOML)
175        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    /// Set resource_type for all resources in a vector.
187    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    /// Save private lockfile to disk.
197    ///
198    /// Deletes the file if the lockfile is empty (no private resources).
199    ///
200    /// # Example
201    ///
202    /// ```rust,no_run
203    /// use agpm_cli::lockfile::private_lock::PrivateLockFile;
204    /// use std::path::Path;
205    ///
206    /// let lock = PrivateLockFile::new();
207    /// lock.save(Path::new("."))?;
208    /// # Ok::<(), anyhow::Error>(())
209    /// ```
210    pub fn save(&self, project_dir: &Path) -> Result<()> {
211        let path = project_dir.join(PRIVATE_LOCK_FILENAME);
212
213        // Don't create empty lockfiles; delete if exists
214        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    /// Check if the lockfile has any private resources.
231    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    /// Count total number of private resources.
242    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    /// Get all resources from the private lockfile.
253    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    /// Create a private lockfile from a vector of private resources.
266    ///
267    /// Filters and distributes resources into appropriate type vectors.
268    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
287/// Serialize private lockfile to TOML string.
288///
289/// Uses the same serialization format as the main lockfile.
290fn serialize_private_lockfile(lockfile: &PrivateLockFile) -> Result<String> {
291    use toml_edit::{DocumentMut, Item};
292
293    // First serialize to a toml_edit document
294    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    // Convert all `applied_patches` and `variant_inputs` tables to inline tables
299    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                // Convert applied_patches to inline table
306                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                // Convert variant_inputs to inline table
317                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        }
361    }
362
363    #[test]
364    fn test_new_lockfile_is_empty() {
365        let lock = PrivateLockFile::new();
366        assert!(lock.is_empty());
367        assert_eq!(lock.total_resources(), 0);
368    }
369
370    #[test]
371    fn test_from_resources() {
372        let resources = vec![
373            create_test_resource("agent1", ResourceType::Agent),
374            create_test_resource("snippet1", ResourceType::Snippet),
375            create_test_resource("command1", ResourceType::Command),
376        ];
377
378        let lock = PrivateLockFile::from_resources(resources);
379
380        assert!(!lock.is_empty());
381        assert_eq!(lock.total_resources(), 3);
382        assert_eq!(lock.agents.len(), 1);
383        assert_eq!(lock.snippets.len(), 1);
384        assert_eq!(lock.commands.len(), 1);
385    }
386
387    #[test]
388    fn test_save_and_load() {
389        let temp_dir = TempDir::new().unwrap();
390        let resources = vec![create_test_resource("test-agent", ResourceType::Agent)];
391        let lock = PrivateLockFile::from_resources(resources);
392
393        // Save
394        lock.save(temp_dir.path()).unwrap();
395
396        // Load
397        let loaded = PrivateLockFile::load(temp_dir.path()).unwrap();
398        assert!(loaded.is_some());
399        let loaded_lock = loaded.unwrap();
400        assert_eq!(loaded_lock.agents.len(), 1);
401        assert_eq!(loaded_lock.agents[0].name, "test-agent");
402        assert_eq!(loaded_lock.agents[0].resource_type, ResourceType::Agent);
403    }
404
405    #[test]
406    fn test_empty_lockfile_deletes_file() {
407        let temp_dir = TempDir::new().unwrap();
408        let lock_path = temp_dir.path().join(PRIVATE_LOCK_FILENAME);
409
410        // Create file
411        std::fs::write(&lock_path, "test").unwrap();
412        assert!(lock_path.exists());
413
414        // Save empty lockfile should delete
415        let lock = PrivateLockFile::new();
416        lock.save(temp_dir.path()).unwrap();
417        assert!(!lock_path.exists());
418    }
419
420    #[test]
421    fn test_load_nonexistent_returns_none() {
422        let temp_dir = TempDir::new().unwrap();
423        let result = PrivateLockFile::load(temp_dir.path()).unwrap();
424        assert!(result.is_none());
425    }
426
427    #[test]
428    fn test_all_resources() {
429        let resources = vec![
430            create_test_resource("agent1", ResourceType::Agent),
431            create_test_resource("agent2", ResourceType::Agent),
432            create_test_resource("snippet1", ResourceType::Snippet),
433        ];
434
435        let lock = PrivateLockFile::from_resources(resources);
436        let all = lock.all_resources();
437
438        assert_eq!(all.len(), 3);
439    }
440
441    #[test]
442    fn test_lockfile_split_by_privacy() {
443        use crate::lockfile::LockFile;
444
445        // Create a lockfile with both public and private resources
446        let mut lockfile = LockFile::new();
447
448        // Add public agents
449        let public_agent = LockedResource {
450            name: "public-agent".to_string(),
451            source: Some("test".to_string()),
452            url: Some("https://github.com/test/repo.git".to_string()),
453            path: "agents/public.md".to_string(),
454            version: Some("v1.0.0".to_string()),
455            resolved_commit: Some("abc123".to_string()),
456            checksum: "sha256:test".to_string(),
457            context_checksum: None,
458            installed_at: ".claude/agents/agpm/public.md".to_string(),
459            dependencies: Vec::new(),
460            resource_type: ResourceType::Agent,
461            tool: Some("claude-code".to_string()),
462            manifest_alias: Some("public-agent".to_string()),
463            applied_patches: BTreeMap::new(),
464            install: None,
465            variant_inputs: VariantInputs::default(),
466            is_private: false,
467        };
468
469        // Add private agent
470        let private_agent = LockedResource {
471            name: "private-agent".to_string(),
472            source: Some("private".to_string()),
473            url: Some("git@github.com:me/private.git".to_string()),
474            path: "agents/private.md".to_string(),
475            version: Some("v1.0.0".to_string()),
476            resolved_commit: Some("def456".to_string()),
477            checksum: "sha256:private".to_string(),
478            context_checksum: None,
479            installed_at: ".claude/agents/agpm/private/private.md".to_string(),
480            dependencies: Vec::new(),
481            resource_type: ResourceType::Agent,
482            tool: Some("claude-code".to_string()),
483            manifest_alias: Some("private-agent".to_string()),
484            applied_patches: BTreeMap::new(),
485            install: None,
486            variant_inputs: VariantInputs::default(),
487            is_private: true,
488        };
489
490        lockfile.agents.push(public_agent);
491        lockfile.agents.push(private_agent);
492
493        // Split by privacy
494        let (public_lock, private_lock) = lockfile.split_by_privacy();
495
496        // Public lockfile should have only the public agent
497        assert_eq!(public_lock.agents.len(), 1);
498        assert_eq!(public_lock.agents[0].name, "public-agent");
499        assert!(!public_lock.agents[0].is_private);
500
501        // Private lockfile should have only the private agent
502        assert_eq!(private_lock.agents.len(), 1);
503        assert_eq!(private_lock.agents[0].name, "private-agent");
504        assert!(private_lock.agents[0].is_private);
505    }
506
507    #[test]
508    fn test_lockfile_merge_private() {
509        use crate::lockfile::LockFile;
510
511        // Create a public lockfile
512        let mut public_lock = LockFile::new();
513        public_lock.agents.push(LockedResource {
514            name: "public-agent".to_string(),
515            source: Some("test".to_string()),
516            url: Some("https://github.com/test/repo.git".to_string()),
517            path: "agents/public.md".to_string(),
518            version: Some("v1.0.0".to_string()),
519            resolved_commit: Some("abc123".to_string()),
520            checksum: "sha256:test".to_string(),
521            context_checksum: None,
522            installed_at: ".claude/agents/agpm/public.md".to_string(),
523            dependencies: Vec::new(),
524            resource_type: ResourceType::Agent,
525            tool: Some("claude-code".to_string()),
526            manifest_alias: Some("public-agent".to_string()),
527            applied_patches: BTreeMap::new(),
528            install: None,
529            variant_inputs: VariantInputs::default(),
530            is_private: false,
531        });
532        public_lock.resource_count = Some(1);
533
534        // Create private lockfile
535        let private_lock = PrivateLockFile::from_resources(vec![create_test_resource(
536            "private-agent",
537            ResourceType::Agent,
538        )]);
539
540        // Merge private into public
541        public_lock.merge_private(&private_lock);
542
543        // Should now have both agents
544        assert_eq!(public_lock.agents.len(), 2);
545        assert!(public_lock.agents.iter().any(|a| a.name == "public-agent"));
546        assert!(public_lock.agents.iter().any(|a| a.name == "private-agent"));
547
548        // Resource count should be updated
549        assert_eq!(public_lock.resource_count, Some(2));
550    }
551
552    #[test]
553    fn test_split_and_merge_roundtrip() {
554        use crate::lockfile::LockFile;
555
556        // Create original lockfile with mixed resources
557        let mut original = LockFile::new();
558        original.agents.push(LockedResource {
559            name: "public".to_string(),
560            source: Some("test".to_string()),
561            url: Some("https://github.com/test/repo.git".to_string()),
562            path: "agents/public.md".to_string(),
563            version: Some("v1.0.0".to_string()),
564            resolved_commit: Some("abc123".to_string()),
565            checksum: "sha256:test".to_string(),
566            context_checksum: None,
567            installed_at: ".claude/agents/agpm/public.md".to_string(),
568            dependencies: Vec::new(),
569            resource_type: ResourceType::Agent,
570            tool: Some("claude-code".to_string()),
571            manifest_alias: Some("public".to_string()),
572            applied_patches: BTreeMap::new(),
573            install: None,
574            variant_inputs: VariantInputs::default(),
575            is_private: false,
576        });
577        original.agents.push(LockedResource {
578            name: "private".to_string(),
579            source: Some("private".to_string()),
580            url: Some("git@github.com:me/private.git".to_string()),
581            path: "agents/private.md".to_string(),
582            version: Some("v1.0.0".to_string()),
583            resolved_commit: Some("def456".to_string()),
584            checksum: "sha256:private".to_string(),
585            context_checksum: None,
586            installed_at: ".claude/agents/agpm/private/private.md".to_string(),
587            dependencies: Vec::new(),
588            resource_type: ResourceType::Agent,
589            tool: Some("claude-code".to_string()),
590            manifest_alias: Some("private".to_string()),
591            applied_patches: BTreeMap::new(),
592            install: None,
593            variant_inputs: VariantInputs::default(),
594            is_private: true,
595        });
596
597        // Split
598        let (mut public_lock, private_lock) = original.split_by_privacy();
599
600        // After split, public should have 1, private should have 1
601        assert_eq!(public_lock.agents.len(), 1);
602        assert_eq!(private_lock.agents.len(), 1);
603
604        // Merge back
605        public_lock.merge_private(&private_lock);
606
607        // Should be back to 2
608        assert_eq!(public_lock.agents.len(), 2);
609    }
610}