Skip to main content

agentctl/skill/
lock.rs

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}