1use crate::error::{ConfigError, Result};
8use actr_protocol::ServiceSpec;
9use serde::{Deserialize, Serialize};
10use std::path::Path;
11use std::str::FromStr;
12
13#[derive(Debug, Clone, Serialize, Deserialize, Default)]
35pub struct LockFile {
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub metadata: Option<LockMetadata>,
39
40 #[serde(rename = "dependency", default, skip_serializing_if = "Vec::is_empty")]
42 pub dependencies: Vec<LockedDependency>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct LockMetadata {
48 pub version: u32,
50 pub generated_at: String,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct LockedDependency {
57 pub name: String,
59
60 pub actr_type: String,
62
63 #[serde(skip_serializing_if = "Option::is_none")]
65 pub description: Option<String>,
66
67 pub fingerprint: String,
69
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub published_at: Option<i64>,
73
74 #[serde(default, skip_serializing_if = "Vec::is_empty")]
76 pub tags: Vec<String>,
77
78 pub cached_at: String,
80
81 #[serde(rename = "files")]
83 pub files: Vec<LockedProtoFile>,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct LockedProtoFile {
89 pub path: String,
91
92 pub fingerprint: String,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct ServiceSpecMeta {
100 pub name: String,
101
102 #[serde(skip_serializing_if = "Option::is_none")]
103 pub description: Option<String>,
104
105 pub fingerprint: String,
107
108 #[serde(rename = "files")]
110 pub protobufs: Vec<ProtoFileMeta>,
111
112 #[serde(skip_serializing_if = "Option::is_none")]
114 pub published_at: Option<i64>,
115
116 #[serde(default, skip_serializing_if = "Vec::is_empty")]
118 pub tags: Vec<String>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct ProtoFileMeta {
125 pub path: String,
127
128 pub fingerprint: String,
130}
131
132impl From<ServiceSpec> for ServiceSpecMeta {
137 fn from(spec: ServiceSpec) -> Self {
138 Self {
139 name: spec.name,
140 description: spec.description,
141 fingerprint: spec.fingerprint,
142 protobufs: spec
143 .protobufs
144 .into_iter()
145 .map(|proto| ProtoFileMeta {
146 path: format!("{}.proto", proto.package),
147 fingerprint: proto.fingerprint,
148 })
149 .collect(),
150 published_at: spec.published_at,
151 tags: spec.tags,
152 }
153 }
154}
155
156impl From<ServiceSpecMeta> for ServiceSpec {
157 fn from(meta: ServiceSpecMeta) -> Self {
158 Self {
159 name: meta.name,
160 description: meta.description,
161 fingerprint: meta.fingerprint,
162 protobufs: meta
163 .protobufs
164 .into_iter()
165 .map(|proto| actr_protocol::service_spec::Protobuf {
166 package: package_from_path(&proto.path),
167 content: String::new(), fingerprint: proto.fingerprint,
169 })
170 .collect(),
171 published_at: meta.published_at,
172 tags: meta.tags,
173 }
174 }
175}
176
177impl LockFile {
182 pub fn new() -> Self {
184 Self {
185 metadata: Some(LockMetadata {
186 version: 1,
187 generated_at: chrono::Utc::now().to_rfc3339(),
188 }),
189 dependencies: Vec::new(),
190 }
191 }
192
193 pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
195 let content = std::fs::read_to_string(path)?;
196 content.parse()
197 }
198
199 pub fn save_to_file(&self, path: impl AsRef<Path>) -> Result<()> {
201 let content = toml::to_string_pretty(self)?;
202 std::fs::write(path, content)?;
203 Ok(())
204 }
205
206 pub fn add_dependency(&mut self, dep: LockedDependency) {
208 self.dependencies.retain(|d| d.name != dep.name);
210
211 self.dependencies.push(dep);
213
214 self.dependencies.sort_by(|a, b| a.name.cmp(&b.name));
216 }
217
218 pub fn get_dependency(&self, name: &str) -> Option<&LockedDependency> {
220 self.dependencies.iter().find(|d| d.name == name)
221 }
222
223 pub fn remove_dependency(&mut self, name: &str) -> bool {
225 let before = self.dependencies.len();
226 self.dependencies.retain(|d| d.name != name);
227 self.dependencies.len() != before
228 }
229
230 pub fn update_timestamp(&mut self) {
232 if let Some(ref mut metadata) = self.metadata {
233 metadata.generated_at = chrono::Utc::now().to_rfc3339();
234 }
235 }
236}
237
238impl FromStr for LockFile {
239 type Err = ConfigError;
240
241 fn from_str(s: &str) -> Result<Self> {
242 toml::from_str(s).map_err(ConfigError::from)
243 }
244}
245
246impl LockedDependency {
247 pub fn new(actr_type: String, spec: ServiceSpecMeta) -> Self {
249 Self {
250 name: spec.name,
251 actr_type,
252 description: spec.description,
253 fingerprint: spec.fingerprint,
254 published_at: spec.published_at,
255 tags: spec.tags,
256 cached_at: chrono::Utc::now().to_rfc3339(),
257 files: spec
258 .protobufs
259 .iter()
260 .map(|p| LockedProtoFile {
261 path: p.path.clone(),
262 fingerprint: p.fingerprint.clone(),
263 })
264 .collect(),
265 }
266 }
267
268 pub fn service_fingerprint(&self) -> &str {
270 &self.fingerprint
271 }
272
273 pub fn file_fingerprints(&self) -> &[LockedProtoFile] {
275 &self.files
276 }
277}
278
279fn package_from_path(path: &str) -> String {
280 Path::new(path)
281 .file_stem()
282 .map(|stem| stem.to_string_lossy().to_string())
283 .unwrap_or_else(|| path.trim_end_matches(".proto").to_string())
284}
285
286#[cfg(test)]
291mod tests {
292 use super::*;
293
294 #[test]
295 fn test_service_spec_conversion() {
296 let spec = ServiceSpec {
297 name: "test-service".to_string(),
298 description: Some("Test service".to_string()),
299 fingerprint: "service_semantic:abc123".to_string(),
300 protobufs: vec![actr_protocol::service_spec::Protobuf {
301 package: "user.v1".to_string(),
302 content: "syntax = \"proto3\";".to_string(),
303 fingerprint: "semantic:xyz".to_string(),
304 }],
305 published_at: Some(1705315800),
306 tags: vec!["latest".to_string(), "stable".to_string()],
307 };
308
309 let meta: ServiceSpecMeta = spec.clone().into();
311 assert_eq!(meta.protobufs.len(), 1);
312 assert_eq!(meta.protobufs[0].path, "user.v1.proto");
313 assert_eq!(meta.protobufs[0].fingerprint, "semantic:xyz");
314 assert_eq!(meta.published_at, Some(1705315800));
315 assert_eq!(meta.tags.len(), 2);
316
317 let restored: ServiceSpec = meta.into();
319 assert_eq!(restored.fingerprint, spec.fingerprint);
320 assert_eq!(restored.protobufs.len(), 1);
321 assert_eq!(restored.protobufs[0].package, spec.protobufs[0].package);
322 assert_eq!(restored.protobufs[0].content, "");
324 }
325
326 #[test]
327 fn test_lock_file_operations() {
328 let mut lock_file = LockFile::new();
329 assert!(lock_file.dependencies.is_empty());
330
331 let spec_meta = ServiceSpecMeta {
332 name: "test-service".to_string(),
333 description: None,
334 fingerprint: "service_semantic:test".to_string(),
335 protobufs: vec![],
336 published_at: None,
337 tags: vec![],
338 };
339
340 let dep = LockedDependency::new("acme+test-service".to_string(), spec_meta);
341
342 lock_file.add_dependency(dep);
343 assert_eq!(lock_file.dependencies.len(), 1);
344
345 let found = lock_file.get_dependency("test-service");
346 assert!(found.is_some());
347 assert_eq!(found.unwrap().actr_type, "acme+test-service");
348
349 let removed = lock_file.remove_dependency("test-service");
350 assert!(removed);
351 assert!(lock_file.dependencies.is_empty());
352 }
353
354 #[test]
355 fn test_lock_file_serialization() {
356 let mut lock_file = LockFile::new();
357
358 let spec_meta = ServiceSpecMeta {
359 name: "user-service".to_string(),
360 description: Some("User service".to_string()),
361 fingerprint: "service_semantic:abc123".to_string(),
362 protobufs: vec![ProtoFileMeta {
363 path: "user-service/user.v1.proto".to_string(),
364 fingerprint: "semantic:xyz".to_string(),
365 }],
366 published_at: Some(1705315800),
367 tags: vec!["latest".to_string()],
368 };
369
370 let dep = LockedDependency::new("acme+user-service".to_string(), spec_meta);
371
372 lock_file.add_dependency(dep);
373
374 let toml_str = toml::to_string_pretty(&lock_file).unwrap();
376 assert!(toml_str.contains("user-service"));
377 assert!(toml_str.contains("user-service/user.v1.proto"));
378 assert!(!toml_str.contains("syntax = \"proto3\""));
380 assert!(toml_str.contains("service_semantic:abc123"));
381
382 let restored: LockFile = toml::from_str(&toml_str).unwrap();
384 assert_eq!(restored.dependencies.len(), 1);
385 assert_eq!(restored.dependencies[0].name, "user-service");
386 assert_eq!(
387 restored.dependencies[0].files[0].path,
388 "user-service/user.v1.proto"
389 );
390 assert_eq!(
391 restored.dependencies[0].files[0].fingerprint,
392 "semantic:xyz"
393 );
394 }
395
396 #[test]
397 fn test_path_serialization() {
398 let spec_meta = ServiceSpecMeta {
399 name: "user-service".to_string(),
400 description: None,
401 fingerprint: "service_semantic:test".to_string(),
402 protobufs: vec![ProtoFileMeta {
403 path: "user-service/user.v1.proto".to_string(),
404 fingerprint: "semantic:abc".to_string(),
405 }],
406 published_at: None,
407 tags: vec![],
408 };
409
410 let toml_str = toml::to_string_pretty(&spec_meta).unwrap();
412
413 assert!(toml_str.contains("path = \"user-service/user.v1.proto\""));
415
416 let restored: ServiceSpecMeta = toml::from_str(&toml_str).unwrap();
418 assert_eq!(restored.protobufs[0].path, "user-service/user.v1.proto");
419 }
420}