1use anyhow::{Context, Result};
45use serde::{Deserialize, Serialize};
46use std::collections::HashMap;
47use std::path::Path;
48
49const PRIVATE_LOCK_FILENAME: &str = "agpm.private.lock";
50const PRIVATE_LOCK_VERSION: u32 = 1;
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
58pub struct PrivateLockedResource {
59 pub name: String,
61
62 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
67 pub applied_patches: HashMap<String, toml::Value>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
78pub struct PrivateLockFile {
79 pub version: u32,
81
82 #[serde(default, skip_serializing_if = "Vec::is_empty")]
84 pub agents: Vec<PrivateLockedResource>,
85
86 #[serde(default, skip_serializing_if = "Vec::is_empty")]
88 pub snippets: Vec<PrivateLockedResource>,
89
90 #[serde(default, skip_serializing_if = "Vec::is_empty")]
92 pub commands: Vec<PrivateLockedResource>,
93
94 #[serde(default, skip_serializing_if = "Vec::is_empty")]
96 pub scripts: Vec<PrivateLockedResource>,
97
98 #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "mcp-servers")]
100 pub mcp_servers: Vec<PrivateLockedResource>,
101
102 #[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 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 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 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 pub fn save(&self, project_dir: &Path) -> Result<()> {
185 let path = project_dir.join(PRIVATE_LOCK_FILENAME);
186
187 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 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 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 pub fn add_private_patches(
242 &mut self,
243 resource_type: &str,
244 name: &str,
245 patches: HashMap<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 vec.retain(|r| r.name != name);
263
264 vec.push(PrivateLockedResource {
266 name: name.to_string(),
267 applied_patches: patches,
268 });
269 }
270
271 pub fn get_patches(
273 &self,
274 resource_type: &str,
275 name: &str,
276 ) -> Option<&HashMap<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
291fn serialize_private_lockfile_with_inline_patches(lockfile: &PrivateLockFile) -> Result<String> {
309 use toml_edit::{DocumentMut, Item};
310
311 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 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 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 tempfile::TempDir;
343
344 #[test]
345 fn test_new_lockfile_is_empty() {
346 let lock = PrivateLockFile::new();
347 assert!(lock.is_empty());
348 assert_eq!(lock.total_patches(), 0);
349 }
350
351 #[test]
352 fn test_add_private_patches() {
353 let mut lock = PrivateLockFile::new();
354 let patches = HashMap::from([
355 ("model".to_string(), toml::Value::String("haiku".into())),
356 ("temp".to_string(), toml::Value::String("0.9".into())),
357 ]);
358
359 lock.add_private_patches("agents", "my-agent", patches);
360
361 assert!(!lock.is_empty());
362 assert_eq!(lock.total_patches(), 1);
363 assert!(lock.agents.iter().any(|r| r.name == "my-agent"));
364 }
365
366 #[test]
367 fn test_empty_patches_not_added() {
368 let mut lock = PrivateLockFile::new();
369 lock.add_private_patches("agents", "my-agent", HashMap::new());
370 assert!(lock.is_empty());
371 }
372
373 #[test]
374 fn test_save_and_load() {
375 let temp_dir = TempDir::new().unwrap();
376 let mut lock = PrivateLockFile::new();
377
378 let patches = HashMap::from([("model".to_string(), toml::Value::String("haiku".into()))]);
379 lock.add_private_patches("agents", "test", patches);
380
381 lock.save(temp_dir.path()).unwrap();
383
384 let loaded = PrivateLockFile::load(temp_dir.path()).unwrap();
386 assert!(loaded.is_some());
387 assert_eq!(loaded.unwrap(), lock);
388 }
389
390 #[test]
391 fn test_empty_lockfile_deletes_file() {
392 let temp_dir = TempDir::new().unwrap();
393 let lock_path = temp_dir.path().join(PRIVATE_LOCK_FILENAME);
394
395 std::fs::write(&lock_path, "test").unwrap();
397 assert!(lock_path.exists());
398
399 let lock = PrivateLockFile::new();
401 lock.save(temp_dir.path()).unwrap();
402 assert!(!lock_path.exists());
403 }
404
405 #[test]
406 fn test_load_nonexistent_returns_none() {
407 let temp_dir = TempDir::new().unwrap();
408 let result = PrivateLockFile::load(temp_dir.path()).unwrap();
409 assert!(result.is_none());
410 }
411
412 #[test]
413 fn test_get_patches() {
414 let mut lock = PrivateLockFile::new();
415 let patches = HashMap::from([("model".to_string(), toml::Value::String("haiku".into()))]);
416 lock.add_private_patches("agents", "test", patches.clone());
417
418 let retrieved = lock.get_patches("agents", "test");
419 assert!(retrieved.is_some());
420 assert_eq!(retrieved.unwrap(), &patches);
421
422 let missing = lock.get_patches("agents", "nonexistent");
423 assert!(missing.is_none());
424 }
425}