use serde::{Deserialize, Serialize};
use crate::model::{ResourceKind, Via};
use crate::path::RelPath;
pub const LOCK_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Lock {
pub version: u32,
#[serde(default, rename = "entry", skip_serializing_if = "Vec::is_empty")]
pub entries: Vec<LockEntry>,
}
impl Default for Lock {
fn default() -> Self {
Self {
version: LOCK_VERSION,
entries: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LockEntry {
pub provider: String,
pub resource: ResourceKind,
pub target: RelPath,
pub canonical: RelPath,
pub via: Via,
}
impl Lock {
pub fn parse(text: &str) -> Result<Self, LockError> {
let lock: Lock = toml::from_str(text).map_err(|err| LockError::Syntax(err.to_string()))?;
if lock.version != LOCK_VERSION {
return Err(LockError::Version(lock.version));
}
Ok(lock)
}
pub fn render(&self) -> String {
let body = toml::to_string_pretty(self).expect("lock is always serialisable");
format!(
"# Generated by agentlink. Records what agentlink created so it can be\n\
# safely updated or removed later. Do not edit by hand.\n\n{body}"
)
}
pub fn owns(&self, target: &RelPath) -> bool {
self.entries.iter().any(|entry| &entry.target == target)
}
pub fn find(&self, provider: &str, resource: ResourceKind) -> Option<&LockEntry> {
self.entries
.iter()
.find(|entry| entry.provider == provider && entry.resource == resource)
}
pub fn record(&mut self, entry: LockEntry) {
self.entries.retain(|existing| {
!(existing.provider == entry.provider && existing.resource == entry.resource)
});
self.entries.push(entry);
self.sort();
}
pub fn forget(&mut self, provider: &str, resource: ResourceKind) -> Option<LockEntry> {
let index = self
.entries
.iter()
.position(|entry| entry.provider == provider && entry.resource == resource)?;
Some(self.entries.remove(index))
}
fn sort(&mut self) {
self.entries.sort_by(|a, b| {
a.resource
.cmp(&b.resource)
.then_with(|| a.provider.cmp(&b.provider))
});
}
}
#[derive(Debug, thiserror::Error)]
pub enum LockError {
#[error("could not parse .agentlink/lock.toml: {0}")]
Syntax(String),
#[error(
"unsupported lock version {0} (this build understands {LOCK_VERSION}); \
upgrade agentlink or delete .agentlink/lock.toml to rebuild it"
)]
Version(u32),
}
#[cfg(test)]
mod tests {
use super::*;
fn rel(s: &str) -> RelPath {
RelPath::new(s).unwrap()
}
fn entry(provider: &str, target: &str) -> LockEntry {
LockEntry {
provider: provider.to_string(),
resource: ResourceKind::Skills,
target: rel(target),
canonical: rel(".agents/skills"),
via: Via::Symlink,
}
}
#[test]
fn round_trips_through_toml() {
let mut lock = Lock::default();
lock.record(entry("claude-code", ".claude/skills"));
lock.record(entry("cursor", ".cursor/skills"));
let parsed = Lock::parse(&lock.render()).expect("round trip");
assert_eq!(parsed, lock);
}
#[test]
fn empty_lock_round_trips() {
let parsed = Lock::parse(&Lock::default().render()).expect("round trip");
assert_eq!(parsed, Lock::default());
}
#[test]
fn ownership_is_the_basis_for_touching_anything() {
let mut lock = Lock::default();
lock.record(entry("claude-code", ".claude/skills"));
assert!(lock.owns(&rel(".claude/skills")));
assert!(!lock.owns(&rel(".cursor/skills")));
}
#[test]
fn recording_the_same_capability_twice_replaces_rather_than_duplicates() {
let mut lock = Lock::default();
lock.record(entry("claude-code", ".claude/skills"));
lock.record(entry("claude-code", ".claude/skills-moved"));
assert_eq!(lock.entries.len(), 1);
assert_eq!(lock.entries[0].target.as_str(), ".claude/skills-moved");
}
#[test]
fn entries_are_stored_in_a_stable_order() {
let mut lock = Lock::default();
lock.record(entry("zed", ".zed/skills"));
lock.record(entry("cursor", ".cursor/skills"));
lock.record(entry("antigravity", ".agents/skills"));
let providers: Vec<_> = lock.entries.iter().map(|e| e.provider.as_str()).collect();
assert_eq!(providers, ["antigravity", "cursor", "zed"]);
}
#[test]
fn forget_removes_exactly_one_record() {
let mut lock = Lock::default();
lock.record(entry("claude-code", ".claude/skills"));
lock.record(entry("cursor", ".cursor/skills"));
let removed = lock
.forget("claude-code", ResourceKind::Skills)
.expect("present");
assert_eq!(removed.target.as_str(), ".claude/skills");
assert_eq!(lock.entries.len(), 1);
assert!(lock.forget("claude-code", ResourceKind::Skills).is_none());
}
#[test]
fn rejects_a_lock_written_by_a_newer_agentlink() {
let err = Lock::parse("version = 99").unwrap_err();
assert!(matches!(err, LockError::Version(99)));
}
}