1use serde::{Deserialize, Serialize};
12
13use crate::model::{ResourceKind, Via};
14use crate::path::RelPath;
15
16pub const LOCK_VERSION: u32 = 1;
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct Lock {
22 pub version: u32,
23 #[serde(default, rename = "entry", skip_serializing_if = "Vec::is_empty")]
24 pub entries: Vec<LockEntry>,
25}
26
27impl Default for Lock {
28 fn default() -> Self {
29 Self {
30 version: LOCK_VERSION,
31 entries: Vec::new(),
32 }
33 }
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct LockEntry {
39 pub provider: String,
40 pub resource: ResourceKind,
41 pub target: RelPath,
42 pub canonical: RelPath,
43 pub via: Via,
44}
45
46impl Lock {
47 pub fn parse(text: &str) -> Result<Self, LockError> {
49 let lock: Lock = toml::from_str(text).map_err(|err| LockError::Syntax(err.to_string()))?;
50 if lock.version != LOCK_VERSION {
51 return Err(LockError::Version(lock.version));
52 }
53 Ok(lock)
54 }
55
56 pub fn render(&self) -> String {
57 let body = toml::to_string_pretty(self).expect("lock is always serialisable");
58 format!(
59 "# Generated by agentlink. Records what agentlink created so it can be\n\
60 # safely updated or removed later. Do not edit by hand.\n\n{body}"
61 )
62 }
63
64 pub fn owns(&self, target: &RelPath) -> bool {
66 self.entries.iter().any(|entry| &entry.target == target)
67 }
68
69 pub fn find(&self, provider: &str, resource: ResourceKind) -> Option<&LockEntry> {
70 self.entries
71 .iter()
72 .find(|entry| entry.provider == provider && entry.resource == resource)
73 }
74
75 pub fn record(&mut self, entry: LockEntry) {
78 self.entries.retain(|existing| {
79 !(existing.provider == entry.provider && existing.resource == entry.resource)
80 });
81 self.entries.push(entry);
82 self.sort();
83 }
84
85 pub fn forget(&mut self, provider: &str, resource: ResourceKind) -> Option<LockEntry> {
87 let index = self
88 .entries
89 .iter()
90 .position(|entry| entry.provider == provider && entry.resource == resource)?;
91 Some(self.entries.remove(index))
92 }
93
94 fn sort(&mut self) {
95 self.entries.sort_by(|a, b| {
96 a.resource
97 .cmp(&b.resource)
98 .then_with(|| a.provider.cmp(&b.provider))
99 });
100 }
101}
102
103#[derive(Debug, thiserror::Error)]
104pub enum LockError {
105 #[error("could not parse .agentlink/lock.toml: {0}")]
106 Syntax(String),
107 #[error(
108 "unsupported lock version {0} (this build understands {LOCK_VERSION}); \
109 upgrade agentlink or delete .agentlink/lock.toml to rebuild it"
110 )]
111 Version(u32),
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 fn rel(s: &str) -> RelPath {
119 RelPath::new(s).unwrap()
120 }
121
122 fn entry(provider: &str, target: &str) -> LockEntry {
123 LockEntry {
124 provider: provider.to_string(),
125 resource: ResourceKind::Skills,
126 target: rel(target),
127 canonical: rel(".agents/skills"),
128 via: Via::Symlink,
129 }
130 }
131
132 #[test]
133 fn round_trips_through_toml() {
134 let mut lock = Lock::default();
135 lock.record(entry("claude-code", ".claude/skills"));
136 lock.record(entry("cursor", ".cursor/skills"));
137
138 let parsed = Lock::parse(&lock.render()).expect("round trip");
139 assert_eq!(parsed, lock);
140 }
141
142 #[test]
143 fn empty_lock_round_trips() {
144 let parsed = Lock::parse(&Lock::default().render()).expect("round trip");
145 assert_eq!(parsed, Lock::default());
146 }
147
148 #[test]
149 fn ownership_is_the_basis_for_touching_anything() {
150 let mut lock = Lock::default();
151 lock.record(entry("claude-code", ".claude/skills"));
152 assert!(lock.owns(&rel(".claude/skills")));
153 assert!(!lock.owns(&rel(".cursor/skills")));
155 }
156
157 #[test]
158 fn recording_the_same_capability_twice_replaces_rather_than_duplicates() {
159 let mut lock = Lock::default();
160 lock.record(entry("claude-code", ".claude/skills"));
161 lock.record(entry("claude-code", ".claude/skills-moved"));
162 assert_eq!(lock.entries.len(), 1);
163 assert_eq!(lock.entries[0].target.as_str(), ".claude/skills-moved");
164 }
165
166 #[test]
167 fn entries_are_stored_in_a_stable_order() {
168 let mut lock = Lock::default();
169 lock.record(entry("zed", ".zed/skills"));
170 lock.record(entry("cursor", ".cursor/skills"));
171 lock.record(entry("antigravity", ".agents/skills"));
172 let providers: Vec<_> = lock.entries.iter().map(|e| e.provider.as_str()).collect();
173 assert_eq!(providers, ["antigravity", "cursor", "zed"]);
174 }
175
176 #[test]
177 fn forget_removes_exactly_one_record() {
178 let mut lock = Lock::default();
179 lock.record(entry("claude-code", ".claude/skills"));
180 lock.record(entry("cursor", ".cursor/skills"));
181 let removed = lock
182 .forget("claude-code", ResourceKind::Skills)
183 .expect("present");
184 assert_eq!(removed.target.as_str(), ".claude/skills");
185 assert_eq!(lock.entries.len(), 1);
186 assert!(lock.forget("claude-code", ResourceKind::Skills).is_none());
187 }
188
189 #[test]
190 fn rejects_a_lock_written_by_a_newer_agentlink() {
191 let err = Lock::parse("version = 99").unwrap_err();
192 assert!(matches!(err, LockError::Version(99)));
193 }
194}