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            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        // Save
395        lock.save(temp_dir.path()).unwrap();
396
397        // Load
398        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        // Create file
412        std::fs::write(&lock_path, "test").unwrap();
413        assert!(lock_path.exists());
414
415        // Save empty lockfile should delete
416        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        // Create a lockfile with both public and private resources
447        let mut lockfile = LockFile::new();
448
449        // Add public agents
450        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        // Add private agent
472        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        // Split by privacy
497        let (public_lock, private_lock) = lockfile.split_by_privacy();
498
499        // Public lockfile should have only the public agent
500        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        // Private lockfile should have only the private agent
505        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        // Create a public lockfile
515        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        // Create private lockfile
539        let private_lock = PrivateLockFile::from_resources(vec![create_test_resource(
540            "private-agent",
541            ResourceType::Agent,
542        )]);
543
544        // Merge private into public
545        public_lock.merge_private(&private_lock);
546
547        // Should now have both agents
548        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        // Resource count should be updated
553        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        // Create original lockfile with mixed resources
561        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        // Split
604        let (mut public_lock, private_lock) = original.split_by_privacy();
605
606        // After split, public should have 1, private should have 1
607        assert_eq!(public_lock.agents.len(), 1);
608        assert_eq!(private_lock.agents.len(), 1);
609
610        // Merge back
611        public_lock.merge_private(&private_lock);
612
613        // Should be back to 2
614        assert_eq!(public_lock.agents.len(), 2);
615    }
616}