1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct LockEntry {
8 pub hub_id: String,
9 pub slug: String,
10 pub version: String,
11 pub commit: String,
12 pub installed_path: String,
13 pub installed_at: String,
14}
15
16#[derive(Debug, Serialize, Deserialize)]
17pub struct LockFile {
18 pub version: String,
19 pub skills: HashMap<String, LockEntry>,
20}
21
22impl Default for LockFile {
23 fn default() -> Self {
24 Self::new()
25 }
26}
27
28impl LockFile {
29 pub fn new() -> Self {
30 Self {
31 version: "1.0".into(),
32 skills: HashMap::new(),
33 }
34 }
35
36 pub fn load(path: &Path) -> Result<Self> {
37 if !path.exists() {
38 return Ok(Self::new());
39 }
40 let s = std::fs::read_to_string(path)?;
41 Ok(serde_json::from_str(&s)?)
42 }
43
44 pub fn save(&self, path: &Path) -> Result<()> {
45 if let Some(parent) = path.parent() {
46 std::fs::create_dir_all(parent)?;
47 }
48 std::fs::write(path, serde_json::to_string_pretty(self)?)?;
49 Ok(())
50 }
51
52 pub fn insert(&mut self, entry: LockEntry) {
53 let key = format!("{}:{}", entry.hub_id, entry.slug);
54 self.skills.insert(key, entry);
55 }
56
57 pub fn remove(&mut self, hub_id: &str, slug: &str) -> bool {
58 let key = format!("{hub_id}:{slug}");
59 self.skills.remove(&key).is_some()
60 }
61
62 pub fn get(&self, hub_id: &str, slug: &str) -> Option<&LockEntry> {
63 let key = format!("{hub_id}:{slug}");
64 self.skills.get(&key)
65 }
66}
67
68pub fn lock_path() -> PathBuf {
69 dirs::home_dir()
70 .unwrap_or_else(|| PathBuf::from("."))
71 .join(".agentctl")
72 .join("skills.lock.json")
73}
74
75#[cfg(test)]
76mod tests {
77 use super::*;
78
79 fn entry(hub: &str, slug: &str) -> LockEntry {
80 LockEntry {
81 hub_id: hub.into(),
82 slug: slug.into(),
83 version: "1.0.0".into(),
84 commit: "abc1234".into(),
85 installed_path: format!("skills/{slug}"),
86 installed_at: "2026-07-15T00:00:00Z".into(),
87 }
88 }
89
90 #[test]
91 fn insert_and_get() {
92 let mut lock = LockFile::new();
93 lock.insert(entry("my-hub", "my-skill"));
94 assert!(lock.get("my-hub", "my-skill").is_some());
95 }
96
97 #[test]
98 fn remove_entry() {
99 let mut lock = LockFile::new();
100 lock.insert(entry("my-hub", "my-skill"));
101 assert!(lock.remove("my-hub", "my-skill"));
102 assert!(lock.get("my-hub", "my-skill").is_none());
103 }
104
105 #[test]
106 fn roundtrip() {
107 let dir = tempfile::tempdir().unwrap();
108 let path = dir.path().join("skills.lock.json");
109 let mut lock = LockFile::new();
110 lock.insert(entry("my-hub", "my-skill"));
111 lock.save(&path).unwrap();
112 let loaded = LockFile::load(&path).unwrap();
113 assert!(loaded.get("my-hub", "my-skill").is_some());
114 }
115
116 #[test]
117 fn load_missing_returns_empty() {
118 let dir = tempfile::tempdir().unwrap();
119 let path = dir.path().join("nonexistent.json");
120 let lock = LockFile::load(&path).unwrap();
121 assert!(lock.skills.is_empty());
122 }
123}