agentlink-domain 0.0.1

Domain model for agentlink: one brain for every AI coding agent
Documentation
//! The ownership record.
//!
//! agentlink only ever removes or repoints something it created. The lock is how
//! it knows. Without it, a `link` sitting where we want to write is ambiguous —
//! it might be ours from a previous run, or it might be something the user set up
//! by hand — and guessing wrong means silently destroying someone's work.
//!
//! The lock is intentionally *not* a cache of filesystem state. It answers
//! exactly one question: "did we put this here?"

use serde::{Deserialize, Serialize};

use crate::model::{ResourceKind, Via};
use crate::path::RelPath;

/// Current on-disk format version.
pub const LOCK_VERSION: u32 = 1;

/// Everything agentlink has materialised in this workspace.
#[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(),
        }
    }
}

/// One materialised capability.
#[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 {
    /// Parses a lock file, tolerating a missing one.
    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}"
        )
    }

    /// Whether agentlink created whatever currently sits at `target`.
    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)
    }

    /// Records a materialised capability, replacing any previous record for the
    /// same provider and 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();
    }

    /// Drops the record for a provider and resource, returning it if present.
    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")));
        // A path we never created must never be reported as ours.
        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)));
    }
}