Skip to main content

agentlink_domain/
lock.rs

1//! The ownership record.
2//!
3//! agentlink only ever removes or repoints something it created. The lock is how
4//! it knows. Without it, a `link` sitting where we want to write is ambiguous —
5//! it might be ours from a previous run, or it might be something the user set up
6//! by hand — and guessing wrong means silently destroying someone's work.
7//!
8//! The lock is intentionally *not* a cache of filesystem state. It answers
9//! exactly one question: "did we put this here?"
10
11use serde::{Deserialize, Serialize};
12
13use crate::model::{ResourceKind, Via};
14use crate::path::RelPath;
15
16/// Current on-disk format version.
17pub const LOCK_VERSION: u32 = 1;
18
19/// Everything agentlink has materialised in this workspace.
20#[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/// One materialised capability.
37#[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    /// Parses a lock file, tolerating a missing one.
48    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    /// Whether agentlink created whatever currently sits at `target`.
65    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    /// Records a materialised capability, replacing any previous record for the
76    /// same provider and resource.
77    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    /// Drops the record for a provider and resource, returning it if present.
86    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        // A path we never created must never be reported as ours.
154        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}