agr 0.1.1

agr — install agent artifacts from the public registry into a project
Documentation
//! `agr.lock` — the record of what was installed. Versioned TOML with stable
//! ordering so diffs are reviewable. Format documented in
//! `docs/registry/LOCKFILE.md`.

use std::path::Path;

use serde::{Deserialize, Serialize};

use crate::Result;

/// Current lockfile schema version.
pub const LOCKFILE_VERSION: u32 = 1;

/// The whole `agr.lock`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Lockfile {
    pub version: u32,
    #[serde(default, rename = "artifact")]
    pub artifacts: Vec<LockEntry>,
}

impl Default for Lockfile {
    fn default() -> Self {
        Self {
            version: LOCKFILE_VERSION,
            artifacts: Vec::new(),
        }
    }
}

/// One installed artifact.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LockEntry {
    pub handle: String,
    pub version: String,
    pub target: String,
    /// sha256 over the concatenated installed file bytes.
    pub content_hash: String,
    /// Destination paths (relative to the install dir), sorted.
    pub files: Vec<String>,
}

impl Lockfile {
    /// Load from `path`, or a default empty lockfile if it does not exist.
    pub fn load(path: &Path) -> Result<Self> {
        match std::fs::read_to_string(path) {
            Ok(text) => Ok(toml::from_str(&text)?),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
            Err(e) => Err(e.into()),
        }
    }

    /// Write to `path` with stable (handle, target) ordering.
    pub fn save(&self, path: &Path) -> Result<()> {
        let mut sorted = self.clone();
        sorted
            .artifacts
            .sort_by(|a, b| (&a.handle, &a.target).cmp(&(&b.handle, &b.target)));
        let text = toml::to_string_pretty(&sorted)?;
        std::fs::write(path, text)?;
        Ok(())
    }

    /// Insert or replace an entry keyed on `(handle, target)`.
    pub fn upsert(&mut self, mut entry: LockEntry) {
        entry.files.sort();
        match self
            .artifacts
            .iter_mut()
            .find(|e| e.handle == entry.handle && e.target == entry.target)
        {
            Some(existing) => *existing = entry,
            None => self.artifacts.push(entry),
        }
    }

    /// Drop the entry keyed on `(handle, target)`. Returns whether one existed.
    pub fn remove(&mut self, handle: &str, target: &str) -> bool {
        let before = self.artifacts.len();
        self.artifacts
            .retain(|e| !(e.handle == handle && e.target == target));
        before != self.artifacts.len()
    }

    /// Look up an entry by `(handle, target)`.
    pub fn get(&self, handle: &str, target: &str) -> Option<&LockEntry> {
        self.artifacts
            .iter()
            .find(|e| e.handle == handle && e.target == target)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn entry(handle: &str, target: &str, version: &str) -> LockEntry {
        LockEntry {
            handle: handle.into(),
            version: version.into(),
            target: target.into(),
            content_hash: "h".into(),
            files: vec!["b".into(), "a".into()],
        }
    }

    #[test]
    fn upsert_replaces_and_sorts_files() {
        let mut lf = Lockfile::default();
        lf.upsert(entry("@a/x", "claude", "v1"));
        lf.upsert(entry("@a/x", "claude", "v2"));
        assert_eq!(lf.artifacts.len(), 1);
        assert_eq!(lf.artifacts[0].version, "v2");
        assert_eq!(lf.artifacts[0].files, vec!["a", "b"]);
    }

    #[test]
    fn remove_only_drops_the_matching_target() {
        let mut lf = Lockfile::default();
        lf.upsert(entry("@a/x", "claude", "v1"));
        lf.upsert(entry("@a/x", "cursor", "v1"));
        assert!(lf.remove("@a/x", "claude"));
        assert_eq!(lf.artifacts.len(), 1);
        assert_eq!(lf.artifacts[0].target, "cursor");
        assert!(!lf.remove("@a/x", "claude"), "second remove is a no-op");
    }

    #[test]
    fn roundtrips_through_toml() {
        let mut lf = Lockfile::default();
        lf.upsert(entry("@a/x", "claude", "v1"));
        let text = toml::to_string_pretty(&lf).unwrap();
        let back: Lockfile = toml::from_str(&text).unwrap();
        assert_eq!(back.version, LOCKFILE_VERSION);
        assert_eq!(back.artifacts, lf.artifacts);
    }
}