Skip to main content

pulith_lock/
lib.rs

1//! Deterministic lock file model and diffing primitives.
2
3use std::collections::BTreeMap;
4
5use pulith_serde_backend::{CodecError, JsonTextCodec, TextCodec};
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9pub type Metadata = BTreeMap<String, String>;
10
11pub const LOCK_SCHEMA_VERSION: u32 = 1;
12
13pub type Result<T> = std::result::Result<T, LockError>;
14
15#[derive(Debug, Error)]
16pub enum LockError {
17    #[error("serialization backend error: {0}")]
18    Codec(#[from] CodecError),
19    #[error("unsupported lock schema version: expected {expected}, got {actual}")]
20    UnsupportedSchemaVersion { expected: u32, actual: u32 },
21    #[error("resource key must not be empty")]
22    EmptyResourceKey,
23    #[error("resource version must not be empty")]
24    EmptyVersion,
25    #[error("resource source must not be empty")]
26    EmptySource,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct LockedResource {
31    pub version: String,
32    pub source: String,
33    pub digest: Option<String>,
34    pub metadata: Metadata,
35}
36
37impl LockedResource {
38    pub fn new(version: impl Into<String>, source: impl Into<String>) -> Self {
39        Self {
40            version: version.into(),
41            source: source.into(),
42            digest: None,
43            metadata: Metadata::new(),
44        }
45    }
46
47    pub fn digest(mut self, digest: impl Into<String>) -> Self {
48        self.digest = Some(digest.into());
49        self
50    }
51
52    pub fn metadata(mut self, metadata: Metadata) -> Self {
53        self.metadata = metadata;
54        self
55    }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct LockFile {
60    pub schema_version: u32,
61    pub resources: BTreeMap<String, LockedResource>,
62    pub metadata: Metadata,
63}
64
65impl Default for LockFile {
66    fn default() -> Self {
67        Self {
68            schema_version: LOCK_SCHEMA_VERSION,
69            resources: BTreeMap::new(),
70            metadata: Metadata::new(),
71        }
72    }
73}
74
75impl LockFile {
76    pub fn upsert(&mut self, resource: impl Into<String>, locked: LockedResource) {
77        self.resources.insert(resource.into(), locked);
78    }
79
80    pub fn to_json(&self) -> Result<String> {
81        self.to_text_with(&JsonTextCodec)
82    }
83
84    pub fn from_json(data: &str) -> Result<Self> {
85        Self::from_text_with(&JsonTextCodec, data)
86    }
87
88    pub fn to_text_with<C: TextCodec>(&self, codec: &C) -> Result<String> {
89        Ok(codec.encode_pretty(self)?)
90    }
91
92    pub fn from_text_with<C: TextCodec>(codec: &C, data: &str) -> Result<Self> {
93        Ok(codec.decode_str(data)?)
94    }
95
96    pub fn from_json_validated(data: &str) -> Result<Self> {
97        let lock = Self::from_json(data)?;
98        lock.validate()?;
99        Ok(lock)
100    }
101
102    pub fn validate(&self) -> Result<()> {
103        if self.schema_version != LOCK_SCHEMA_VERSION {
104            return Err(LockError::UnsupportedSchemaVersion {
105                expected: LOCK_SCHEMA_VERSION,
106                actual: self.schema_version,
107            });
108        }
109
110        for (resource, locked) in &self.resources {
111            if resource.is_empty() {
112                return Err(LockError::EmptyResourceKey);
113            }
114            if locked.version.is_empty() {
115                return Err(LockError::EmptyVersion);
116            }
117            if locked.source.is_empty() {
118                return Err(LockError::EmptySource);
119            }
120        }
121
122        Ok(())
123    }
124
125    pub fn diff(&self, target: &Self) -> LockDiff {
126        let mut added = Vec::with_capacity(target.resources.len());
127        let mut removed = Vec::with_capacity(self.resources.len());
128        let mut changed = Vec::new();
129
130        for (resource, from_locked) in &self.resources {
131            match target.resources.get(resource) {
132                Some(to_locked) if to_locked != from_locked => changed.push(LockResourceChange {
133                    resource: resource.clone(),
134                    before: from_locked.clone(),
135                    after: to_locked.clone(),
136                }),
137                Some(_) => {}
138                None => removed.push((resource.clone(), from_locked.clone())),
139            }
140        }
141
142        for (resource, to_locked) in &target.resources {
143            if !self.resources.contains_key(resource) {
144                added.push((resource.clone(), to_locked.clone()));
145            }
146        }
147
148        added.shrink_to_fit();
149        removed.shrink_to_fit();
150
151        LockDiff {
152            added,
153            removed,
154            changed,
155        }
156    }
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct LockResourceChange {
161    pub resource: String,
162    pub before: LockedResource,
163    pub after: LockedResource,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct LockDiff {
168    pub added: Vec<(String, LockedResource)>,
169    pub removed: Vec<(String, LockedResource)>,
170    pub changed: Vec<LockResourceChange>,
171}
172
173impl LockDiff {
174    pub fn is_empty(&self) -> bool {
175        self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty()
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use pulith_serde_backend::CompactJsonTextCodec;
183
184    #[test]
185    fn lock_json_is_deterministic_by_resource_key_order() {
186        let mut lock = LockFile::default();
187        lock.upsert(
188            "zeta/tool",
189            LockedResource::new("1.0.0", "https://example.com/zeta"),
190        );
191        lock.upsert(
192            "alpha/tool",
193            LockedResource::new("1.0.0", "https://example.com/alpha"),
194        );
195
196        let json = lock.to_json().unwrap();
197        let alpha = json.find("alpha/tool").unwrap();
198        let zeta = json.find("zeta/tool").unwrap();
199
200        assert!(alpha < zeta);
201    }
202
203    #[test]
204    fn lock_round_trip_preserves_content() {
205        let mut lock = LockFile::default();
206        lock.upsert(
207            "example/runtime",
208            LockedResource::new("20.12.1", "https://example.com/runtime.tar.zst")
209                .digest("sha256:abc"),
210        );
211
212        let json = lock.to_json().unwrap();
213        let parsed = LockFile::from_json(&json).unwrap();
214        parsed.validate().unwrap();
215
216        assert_eq!(parsed, lock);
217    }
218
219    #[test]
220    fn lock_diff_reports_added_removed_and_changed_entries() {
221        let mut base = LockFile::default();
222        base.upsert(
223            "example/a",
224            LockedResource::new("1.0.0", "https://example.com/a"),
225        );
226        base.upsert(
227            "example/b",
228            LockedResource::new("1.0.0", "https://example.com/b"),
229        );
230
231        let mut next = LockFile::default();
232        next.upsert(
233            "example/b",
234            LockedResource::new("2.0.0", "https://example.com/b"),
235        );
236        next.upsert(
237            "example/c",
238            LockedResource::new("1.0.0", "https://example.com/c"),
239        );
240
241        let diff = base.diff(&next);
242        assert_eq!(diff.added.len(), 1);
243        assert_eq!(diff.removed.len(), 1);
244        assert_eq!(diff.changed.len(), 1);
245        assert_eq!(diff.added[0].0, "example/c");
246        assert_eq!(diff.removed[0].0, "example/a");
247        assert_eq!(diff.changed[0].resource, "example/b");
248        assert_eq!(diff.changed[0].before.version, "1.0.0");
249        assert_eq!(diff.changed[0].after.version, "2.0.0");
250    }
251
252    #[test]
253    fn lock_diff_is_empty_for_identical_files() {
254        let mut lock = LockFile::default();
255        lock.upsert(
256            "example/runtime",
257            LockedResource::new("1.0.0", "https://example.com/runtime"),
258        );
259
260        let diff = lock.diff(&lock);
261        assert!(diff.is_empty());
262    }
263
264    #[test]
265    fn lock_validate_rejects_wrong_schema() {
266        let lock = LockFile {
267            schema_version: 2,
268            ..LockFile::default()
269        };
270        assert!(matches!(
271            lock.validate(),
272            Err(LockError::UnsupportedSchemaVersion {
273                expected,
274                actual
275            }) if expected == LOCK_SCHEMA_VERSION && actual == 2
276        ));
277    }
278
279    #[test]
280    fn lock_validate_rejects_empty_fields() {
281        let mut lock = LockFile::default();
282        lock.resources.insert(
283            String::new(),
284            LockedResource::new("1.0.0", "https://example.com"),
285        );
286
287        assert!(matches!(lock.validate(), Err(LockError::EmptyResourceKey)));
288    }
289
290    #[test]
291    fn lock_codec_roundtrip_preserves_semantic_parity() {
292        let mut lock = LockFile::default();
293        lock.upsert(
294            "example/runtime",
295            LockedResource::new("1.0.0", "https://example.com/runtime").digest("sha256:abc"),
296        );
297
298        let pretty = lock.to_json().unwrap();
299        let compact = lock.to_text_with(&CompactJsonTextCodec).unwrap();
300
301        let pretty_decoded = LockFile::from_json(&pretty).unwrap();
302        let compact_decoded = LockFile::from_text_with(&CompactJsonTextCodec, &compact).unwrap();
303        let cross_decoded = LockFile::from_json(&compact).unwrap();
304
305        assert_eq!(pretty_decoded, compact_decoded);
306        assert_eq!(pretty_decoded, cross_decoded);
307    }
308}