Skip to main content

skillfile_core/
lock.rs

1use std::collections::BTreeMap;
2use std::path::Path;
3
4use crate::error::SkillfileError;
5use crate::models::{Entry, LockEntry};
6
7pub const LOCK_NAME: &str = "Skillfile.lock";
8
9/// Generate the lock file key for an entry: `"{source_type}/{entity_type}/{name}"`.
10///
11/// ```
12/// use skillfile_core::models::*;
13/// use skillfile_core::lock::lock_key;
14///
15/// let entry = Entry {
16///     entity_type: EntityType::Agent,
17///     name: "code-refactorer".into(),
18///     source: SourceFields::Github {
19///         owner_repo: "owner/repo".into(),
20///         path_in_repo: "agents/code-refactorer.md".into(),
21///         ref_: "main".into(),
22///     },
23/// };
24/// assert_eq!(lock_key(&entry), "github/agent/code-refactorer");
25/// ```
26#[must_use]
27pub fn lock_key(entry: &Entry) -> String {
28    format!(
29        "{}/{}/{}",
30        entry.source_type(),
31        entry.entity_type,
32        entry.name
33    )
34}
35
36/// Read lock entries from `Skillfile.lock`. Returns empty map if file is missing.
37pub fn read_lock(repo_root: &Path) -> Result<BTreeMap<String, LockEntry>, SkillfileError> {
38    let lock_path = repo_root.join(LOCK_NAME);
39    if !lock_path.exists() {
40        return Ok(BTreeMap::new());
41    }
42    let text = std::fs::read_to_string(&lock_path)?;
43    let data: BTreeMap<String, LockEntry> = serde_json::from_str(&text)
44        .map_err(|e| SkillfileError::Manifest(format!("invalid lock file: {e}")))?;
45    Ok(data)
46}
47
48/// Write lock entries to `Skillfile.lock` with sorted keys, 2-space indent, trailing newline.
49pub fn write_lock(
50    repo_root: &Path,
51    locked: &BTreeMap<String, LockEntry>,
52) -> Result<(), SkillfileError> {
53    let lock_path = repo_root.join(LOCK_NAME);
54    // BTreeMap iterates in sorted order, matching Python's sort_keys=True
55    let json = serde_json::to_string_pretty(locked)
56        .map_err(|e| SkillfileError::Manifest(format!("failed to serialize lock: {e}")))?;
57    std::fs::write(&lock_path, format!("{json}\n"))?;
58    Ok(())
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64
65    fn make_github_entry(name: &str) -> Entry {
66        use crate::models::{EntityType, SourceFields};
67        Entry {
68            entity_type: EntityType::Agent,
69            name: name.into(),
70            source: SourceFields::Github {
71                owner_repo: "owner/repo".into(),
72                path_in_repo: "agent.md".into(),
73                ref_: "main".into(),
74            },
75        }
76    }
77
78    #[test]
79    fn lock_key_format() {
80        let e = make_github_entry("my-agent");
81        assert_eq!(lock_key(&e), "github/agent/my-agent");
82    }
83
84    #[test]
85    fn write_lock_valid_json() {
86        let dir = tempfile::tempdir().unwrap();
87        let mut locked = BTreeMap::new();
88        locked.insert(
89            "github/agent/test".to_string(),
90            LockEntry {
91                sha: "abc123".into(),
92                raw_url: "https://example.com/file.md".into(),
93            },
94        );
95        write_lock(dir.path(), &locked).unwrap();
96        let content = std::fs::read_to_string(dir.path().join(LOCK_NAME)).unwrap();
97        let data: serde_json::Value = serde_json::from_str(&content).unwrap();
98        assert_eq!(data["github/agent/test"]["sha"], "abc123");
99        assert_eq!(
100            data["github/agent/test"]["raw_url"],
101            "https://example.com/file.md"
102        );
103    }
104
105    #[test]
106    fn read_lock_missing_file() {
107        let dir = tempfile::tempdir().unwrap();
108        let result = read_lock(dir.path()).unwrap();
109        assert!(result.is_empty());
110    }
111
112    #[test]
113    fn roundtrip() {
114        let dir = tempfile::tempdir().unwrap();
115        let mut locked = BTreeMap::new();
116        locked.insert(
117            "github/agent/foo".to_string(),
118            LockEntry {
119                sha: "deadbeef".into(),
120                raw_url: "https://example.com/foo.md".into(),
121            },
122        );
123        locked.insert(
124            "github/skill/bar".to_string(),
125            LockEntry {
126                sha: "cafebabe".into(),
127                raw_url: "https://example.com/bar.md".into(),
128            },
129        );
130        write_lock(dir.path(), &locked).unwrap();
131        let result = read_lock(dir.path()).unwrap();
132        assert_eq!(result, locked);
133    }
134
135    #[test]
136    fn write_lock_sorted_keys() {
137        let dir = tempfile::tempdir().unwrap();
138        let mut locked = BTreeMap::new();
139        locked.insert(
140            "github/skill/zebra".to_string(),
141            LockEntry {
142                sha: "aaa".into(),
143                raw_url: "https://example.com/z.md".into(),
144            },
145        );
146        locked.insert(
147            "github/agent/alpha".to_string(),
148            LockEntry {
149                sha: "bbb".into(),
150                raw_url: "https://example.com/a.md".into(),
151            },
152        );
153        write_lock(dir.path(), &locked).unwrap();
154        let content = std::fs::read_to_string(dir.path().join(LOCK_NAME)).unwrap();
155        let alpha_pos = content.find("alpha").unwrap();
156        let zebra_pos = content.find("zebra").unwrap();
157        assert!(alpha_pos < zebra_pos);
158    }
159}