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#[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
36pub 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
48pub 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 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}