agpm_cli/lockfile/
private_lock.rs

1//! Private lockfile management for user-level patches.
2//!
3//! The private lockfile (`agpm.private.lock`) tracks patches from `agpm.private.toml`
4//! separately from the project lockfile. This allows team members to have different
5//! private patches without causing lockfile conflicts.
6//!
7//! # Structure
8//!
9//! The private lockfile uses the same array-based format as `agpm.lock`, storing
10//! only resources that have private patches applied:
11//!
12//! ```toml
13//! version = 1
14//!
15//! [[agents]]
16//! name = "my-agent"
17//! applied_patches = { temperature = "0.9", custom_field = "value" }
18//!
19//! [[commands]]
20//! name = "deploy"
21//! applied_patches = { timeout = "300" }
22//! ```
23//!
24//! # Usage
25//!
26//! ```rust,no_run
27//! use agpm_cli::lockfile::private_lock::PrivateLockFile;
28//! use std::path::Path;
29//!
30//! let project_dir = Path::new(".");
31//! let mut private_lock = PrivateLockFile::new();
32//!
33//! // Add private patches for a resource
34//! let patches = std::collections::BTreeMap::from([
35//!     ("temperature".to_string(), toml::Value::String("0.9".into())),
36//! ]);
37//! private_lock.add_private_patches("agents", "my-agent", patches);
38//!
39//! // Save to disk - creates an array-based lockfile matching agpm.lock format
40//! private_lock.save(project_dir)?;
41//! # Ok::<(), anyhow::Error>(())
42//! ```
43
44use anyhow::{Context, Result};
45use serde::{Deserialize, Serialize};
46use std::collections::BTreeMap;
47use std::path::Path;
48
49const PRIVATE_LOCK_FILENAME: &str = "agpm.private.lock";
50const PRIVATE_LOCK_VERSION: u32 = 1;
51
52/// A resource entry in the private lockfile.
53///
54/// Contains only the essential fields needed to identify a resource and
55/// track its private patches. This matches the structure used in `agpm.lock`
56/// but stores only resources with private patches.
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
58pub struct PrivateLockedResource {
59    /// Resource name from the manifest
60    pub name: String,
61
62    /// Applied private patches
63    ///
64    /// Contains the key-value pairs from `agpm.private.toml` that override
65    /// the resource's default configuration or project-level patches.
66    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
67    pub applied_patches: BTreeMap<String, toml::Value>,
68}
69
70/// Private lockfile tracking user-level patches.
71///
72/// This file is gitignored and contains patches from `agpm.private.toml` only.
73/// It works alongside `agpm.lock` to provide full reproducibility while keeping
74/// team lockfiles deterministic.
75///
76/// Uses the same array-based format as `agpm.lock` for consistency.
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
78pub struct PrivateLockFile {
79    /// Lockfile format version
80    pub version: u32,
81
82    /// Private patches for agents
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub agents: Vec<PrivateLockedResource>,
85
86    /// Private patches for snippets
87    #[serde(default, skip_serializing_if = "Vec::is_empty")]
88    pub snippets: Vec<PrivateLockedResource>,
89
90    /// Private patches for commands
91    #[serde(default, skip_serializing_if = "Vec::is_empty")]
92    pub commands: Vec<PrivateLockedResource>,
93
94    /// Private patches for scripts
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    pub scripts: Vec<PrivateLockedResource>,
97
98    /// Private patches for MCP servers
99    #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "mcp-servers")]
100    pub mcp_servers: Vec<PrivateLockedResource>,
101
102    /// Private patches for hooks
103    #[serde(default, skip_serializing_if = "Vec::is_empty")]
104    pub hooks: Vec<PrivateLockedResource>,
105}
106
107impl Default for PrivateLockFile {
108    fn default() -> Self {
109        Self::new()
110    }
111}
112
113impl PrivateLockFile {
114    /// Create a new empty private lockfile.
115    pub fn new() -> Self {
116        Self {
117            version: PRIVATE_LOCK_VERSION,
118            agents: Vec::new(),
119            snippets: Vec::new(),
120            commands: Vec::new(),
121            scripts: Vec::new(),
122            mcp_servers: Vec::new(),
123            hooks: Vec::new(),
124        }
125    }
126
127    /// Load private lockfile from disk.
128    ///
129    /// Returns `Ok(None)` if the file doesn't exist (no private patches).
130    ///
131    /// # Example
132    ///
133    /// ```rust,no_run
134    /// use agpm_cli::lockfile::private_lock::PrivateLockFile;
135    /// use std::path::Path;
136    ///
137    /// let project_dir = Path::new(".");
138    /// match PrivateLockFile::load(project_dir)? {
139    ///     Some(lock) => println!("Loaded {} private patches", lock.total_patches()),
140    ///     None => println!("No private lockfile found"),
141    /// }
142    /// # Ok::<(), anyhow::Error>(())
143    /// ```
144    pub fn load(project_dir: &Path) -> Result<Option<Self>> {
145        let path = project_dir.join(PRIVATE_LOCK_FILENAME);
146        if !path.exists() {
147            return Ok(None);
148        }
149
150        let content = std::fs::read_to_string(&path)
151            .with_context(|| format!("Failed to read {}", path.display()))?;
152
153        let lock: Self = toml::from_str(&content)
154            .with_context(|| format!("Failed to parse {}", path.display()))?;
155
156        // Validate version
157        if lock.version > PRIVATE_LOCK_VERSION {
158            anyhow::bail!(
159                "Private lockfile version {} is newer than supported version {}. \
160                 Please upgrade AGPM.",
161                lock.version,
162                PRIVATE_LOCK_VERSION
163            );
164        }
165
166        Ok(Some(lock))
167    }
168
169    /// Save private lockfile to disk.
170    ///
171    /// Deletes the file if the lockfile is empty (no private patches).
172    ///
173    /// # Example
174    ///
175    /// ```rust,no_run
176    /// use agpm_cli::lockfile::private_lock::PrivateLockFile;
177    /// use std::path::Path;
178    ///
179    /// let mut lock = PrivateLockFile::new();
180    /// // ... add patches ...
181    /// lock.save(Path::new("."))?;
182    /// # Ok::<(), anyhow::Error>(())
183    /// ```
184    pub fn save(&self, project_dir: &Path) -> Result<()> {
185        let path = project_dir.join(PRIVATE_LOCK_FILENAME);
186
187        // Don't create empty lockfiles; delete if exists
188        if self.is_empty() {
189            if path.exists() {
190                std::fs::remove_file(&path)
191                    .with_context(|| format!("Failed to remove {}", path.display()))?;
192            }
193            return Ok(());
194        }
195
196        let content = serialize_private_lockfile_with_inline_patches(self)?;
197
198        std::fs::write(&path, content)
199            .with_context(|| format!("Failed to write {}", path.display()))?;
200
201        Ok(())
202    }
203
204    /// Check if the lockfile has any patches.
205    pub fn is_empty(&self) -> bool {
206        self.agents.is_empty()
207            && self.snippets.is_empty()
208            && self.commands.is_empty()
209            && self.scripts.is_empty()
210            && self.mcp_servers.is_empty()
211            && self.hooks.is_empty()
212    }
213
214    /// Count total number of resources with private patches.
215    pub fn total_patches(&self) -> usize {
216        self.agents.len()
217            + self.snippets.len()
218            + self.commands.len()
219            + self.scripts.len()
220            + self.mcp_servers.len()
221            + self.hooks.len()
222    }
223
224    /// Add private patches for a resource.
225    ///
226    /// If patches is empty, this is a no-op. If a resource with the same name
227    /// already exists, its patches are replaced.
228    ///
229    /// # Example
230    ///
231    /// ```rust,no_run
232    /// use agpm_cli::lockfile::private_lock::PrivateLockFile;
233    /// use std::collections::BTreeMap;
234    ///
235    /// let mut lock = PrivateLockFile::new();
236    /// let patches = BTreeMap::from([
237    ///     ("model".to_string(), toml::Value::String("haiku".into())),
238    /// ]);
239    /// lock.add_private_patches("agents", "my-agent", patches);
240    /// ```
241    pub fn add_private_patches(
242        &mut self,
243        resource_type: &str,
244        name: &str,
245        patches: BTreeMap<String, toml::Value>,
246    ) {
247        if patches.is_empty() {
248            return;
249        }
250
251        let vec = match resource_type {
252            "agents" => &mut self.agents,
253            "snippets" => &mut self.snippets,
254            "commands" => &mut self.commands,
255            "scripts" => &mut self.scripts,
256            "mcp-servers" => &mut self.mcp_servers,
257            "hooks" => &mut self.hooks,
258            _ => return,
259        };
260
261        // Remove existing entry if present
262        vec.retain(|r| r.name != name);
263
264        // Add new entry
265        vec.push(PrivateLockedResource {
266            name: name.to_string(),
267            applied_patches: patches,
268        });
269    }
270
271    /// Get private patches for a specific resource.
272    pub fn get_patches(
273        &self,
274        resource_type: &str,
275        name: &str,
276    ) -> Option<&BTreeMap<String, toml::Value>> {
277        let vec = match resource_type {
278            "agents" => &self.agents,
279            "snippets" => &self.snippets,
280            "commands" => &self.commands,
281            "scripts" => &self.scripts,
282            "mcp-servers" => &self.mcp_servers,
283            "hooks" => &self.hooks,
284            _ => return None,
285        };
286
287        vec.iter().find(|r| r.name == name).map(|r| &r.applied_patches)
288    }
289}
290
291/// Convert private lockfile to TOML string with inline tables for `applied_patches`.
292///
293/// Uses `toml_edit` to ensure `applied_patches` fields are serialized as inline tables:
294/// ```toml
295/// [[agents]]
296/// name = "my-agent"
297/// applied_patches = { model = "haiku", temperature = "0.9" }
298/// ```
299///
300/// Instead of the confusing separate table format:
301/// ```toml
302/// [[agents]]
303/// name = "my-agent"
304///
305/// [agents.applied_patches]
306/// model = "haiku"
307/// ```
308fn serialize_private_lockfile_with_inline_patches(lockfile: &PrivateLockFile) -> Result<String> {
309    use toml_edit::{DocumentMut, Item};
310
311    // First serialize to a toml_edit document
312    let toml_str =
313        toml::to_string_pretty(lockfile).context("Failed to serialize private lockfile to TOML")?;
314    let mut doc: DocumentMut = toml_str.parse().context("Failed to parse TOML document")?;
315
316    // Convert all `applied_patches` tables to inline tables
317    let resource_types = ["agents", "snippets", "commands", "scripts", "hooks", "mcp-servers"];
318
319    for resource_type in &resource_types {
320        if let Some(Item::ArrayOfTables(array)) = doc.get_mut(resource_type) {
321            for table in array.iter_mut() {
322                if let Some(Item::Table(patches_table)) = table.get_mut("applied_patches") {
323                    // Convert to inline table
324                    let mut inline = toml_edit::InlineTable::new();
325                    for (key, val) in patches_table.iter() {
326                        if let Some(v) = val.as_value() {
327                            inline.insert(key, v.clone());
328                        }
329                    }
330                    table.insert("applied_patches", toml_edit::value(inline));
331                }
332            }
333        }
334    }
335
336    Ok(doc.to_string())
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use std::collections::BTreeMap;
343    use tempfile::TempDir;
344
345    #[test]
346    fn test_new_lockfile_is_empty() {
347        let lock = PrivateLockFile::new();
348        assert!(lock.is_empty());
349        assert_eq!(lock.total_patches(), 0);
350    }
351
352    #[test]
353    fn test_add_private_patches() {
354        let mut lock = PrivateLockFile::new();
355        let patches = BTreeMap::from([
356            ("model".to_string(), toml::Value::String("haiku".into())),
357            ("temp".to_string(), toml::Value::String("0.9".into())),
358        ]);
359
360        lock.add_private_patches("agents", "my-agent", patches);
361
362        assert!(!lock.is_empty());
363        assert_eq!(lock.total_patches(), 1);
364        assert!(lock.agents.iter().any(|r| r.name == "my-agent"));
365    }
366
367    #[test]
368    fn test_empty_patches_not_added() {
369        let mut lock = PrivateLockFile::new();
370        lock.add_private_patches("agents", "my-agent", BTreeMap::new());
371        assert!(lock.is_empty());
372    }
373
374    #[test]
375    fn test_save_and_load() {
376        let temp_dir = TempDir::new().unwrap();
377        let mut lock = PrivateLockFile::new();
378
379        let patches = BTreeMap::from([("model".to_string(), toml::Value::String("haiku".into()))]);
380        lock.add_private_patches("agents", "test", patches);
381
382        // Save
383        lock.save(temp_dir.path()).unwrap();
384
385        // Load
386        let loaded = PrivateLockFile::load(temp_dir.path()).unwrap();
387        assert!(loaded.is_some());
388        assert_eq!(loaded.unwrap(), lock);
389    }
390
391    #[test]
392    fn test_empty_lockfile_deletes_file() {
393        let temp_dir = TempDir::new().unwrap();
394        let lock_path = temp_dir.path().join(PRIVATE_LOCK_FILENAME);
395
396        // Create file
397        std::fs::write(&lock_path, "test").unwrap();
398        assert!(lock_path.exists());
399
400        // Save empty lockfile should delete
401        let lock = PrivateLockFile::new();
402        lock.save(temp_dir.path()).unwrap();
403        assert!(!lock_path.exists());
404    }
405
406    #[test]
407    fn test_load_nonexistent_returns_none() {
408        let temp_dir = TempDir::new().unwrap();
409        let result = PrivateLockFile::load(temp_dir.path()).unwrap();
410        assert!(result.is_none());
411    }
412
413    #[test]
414    fn test_get_patches() {
415        let mut lock = PrivateLockFile::new();
416        let patches = BTreeMap::from([("model".to_string(), toml::Value::String("haiku".into()))]);
417        lock.add_private_patches("agents", "test", patches.clone());
418
419        let retrieved = lock.get_patches("agents", "test");
420        assert!(retrieved.is_some());
421        assert_eq!(retrieved.unwrap(), &patches);
422
423        let missing = lock.get_patches("agents", "nonexistent");
424        assert!(missing.is_none());
425    }
426}