Skip to main content

registry_cli/
lockfile.rs

1//! `agr.lock` — the record of what was installed. Versioned TOML with stable
2//! ordering so diffs are reviewable. Format documented in
3//! `docs/registry/LOCKFILE.md`.
4
5use std::path::Path;
6
7use serde::{Deserialize, Serialize};
8
9use crate::Result;
10
11/// Current lockfile schema version.
12pub const LOCKFILE_VERSION: u32 = 1;
13
14/// The whole `agr.lock`.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Lockfile {
17    pub version: u32,
18    #[serde(default, rename = "artifact")]
19    pub artifacts: Vec<LockEntry>,
20}
21
22impl Default for Lockfile {
23    fn default() -> Self {
24        Self {
25            version: LOCKFILE_VERSION,
26            artifacts: Vec::new(),
27        }
28    }
29}
30
31/// One installed artifact.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct LockEntry {
34    pub handle: String,
35    pub version: String,
36    pub target: String,
37    /// sha256 over the concatenated installed file bytes.
38    pub content_hash: String,
39    /// Destination paths (relative to the install dir), sorted.
40    pub files: Vec<String>,
41}
42
43impl Lockfile {
44    /// Load from `path`, or a default empty lockfile if it does not exist.
45    pub fn load(path: &Path) -> Result<Self> {
46        match std::fs::read_to_string(path) {
47            Ok(text) => Ok(toml::from_str(&text)?),
48            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
49            Err(e) => Err(e.into()),
50        }
51    }
52
53    /// Write to `path` with stable (handle, target) ordering.
54    pub fn save(&self, path: &Path) -> Result<()> {
55        let mut sorted = self.clone();
56        sorted
57            .artifacts
58            .sort_by(|a, b| (&a.handle, &a.target).cmp(&(&b.handle, &b.target)));
59        let text = toml::to_string_pretty(&sorted)?;
60        std::fs::write(path, text)?;
61        Ok(())
62    }
63
64    /// Insert or replace an entry keyed on `(handle, target)`.
65    pub fn upsert(&mut self, mut entry: LockEntry) {
66        entry.files.sort();
67        match self
68            .artifacts
69            .iter_mut()
70            .find(|e| e.handle == entry.handle && e.target == entry.target)
71        {
72            Some(existing) => *existing = entry,
73            None => self.artifacts.push(entry),
74        }
75    }
76
77    /// Drop the entry keyed on `(handle, target)`. Returns whether one existed.
78    pub fn remove(&mut self, handle: &str, target: &str) -> bool {
79        let before = self.artifacts.len();
80        self.artifacts
81            .retain(|e| !(e.handle == handle && e.target == target));
82        before != self.artifacts.len()
83    }
84
85    /// Look up an entry by `(handle, target)`.
86    pub fn get(&self, handle: &str, target: &str) -> Option<&LockEntry> {
87        self.artifacts
88            .iter()
89            .find(|e| e.handle == handle && e.target == target)
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    fn entry(handle: &str, target: &str, version: &str) -> LockEntry {
98        LockEntry {
99            handle: handle.into(),
100            version: version.into(),
101            target: target.into(),
102            content_hash: "h".into(),
103            files: vec!["b".into(), "a".into()],
104        }
105    }
106
107    #[test]
108    fn upsert_replaces_and_sorts_files() {
109        let mut lf = Lockfile::default();
110        lf.upsert(entry("@a/x", "claude", "v1"));
111        lf.upsert(entry("@a/x", "claude", "v2"));
112        assert_eq!(lf.artifacts.len(), 1);
113        assert_eq!(lf.artifacts[0].version, "v2");
114        assert_eq!(lf.artifacts[0].files, vec!["a", "b"]);
115    }
116
117    #[test]
118    fn remove_only_drops_the_matching_target() {
119        let mut lf = Lockfile::default();
120        lf.upsert(entry("@a/x", "claude", "v1"));
121        lf.upsert(entry("@a/x", "cursor", "v1"));
122        assert!(lf.remove("@a/x", "claude"));
123        assert_eq!(lf.artifacts.len(), 1);
124        assert_eq!(lf.artifacts[0].target, "cursor");
125        assert!(!lf.remove("@a/x", "claude"), "second remove is a no-op");
126    }
127
128    #[test]
129    fn roundtrips_through_toml() {
130        let mut lf = Lockfile::default();
131        lf.upsert(entry("@a/x", "claude", "v1"));
132        let text = toml::to_string_pretty(&lf).unwrap();
133        let back: Lockfile = toml::from_str(&text).unwrap();
134        assert_eq!(back.version, LOCKFILE_VERSION);
135        assert_eq!(back.artifacts, lf.artifacts);
136    }
137}