1use 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}