actr_config/
lock.rs

1//! Lock file management for actr.lock.toml
2//!
3//! This module provides lock file structures for dependency locking.
4//! Proto content is cached to the project's `proto/` folder, not in the lock file.
5//! The lock file only records metadata (fingerprints, paths) for version locking.
6
7use crate::error::{ConfigError, Result};
8use actr_protocol::ServiceSpec;
9use serde::{Deserialize, Serialize};
10use std::path::Path;
11use std::str::FromStr;
12
13/// Lock file structure for actr.lock.toml
14///
15/// Format matches documentation spec:
16/// ```toml
17/// [metadata]
18/// version = 1
19/// generated_at = "2024-01-15T10:30:00Z"
20///
21/// [[dependency]]
22/// name = "user-service"
23/// actr_type = "acme+user-service"
24/// description = "User management service"
25/// fingerprint = "service_semantic:a1b2c3d4e5f6..."
26/// published_at = 1705315800
27/// tags = ["latest", "stable"]
28/// cached_at = "2024-01-15T10:30:00Z"
29///
30///   [[dependency.files]]
31///   path = "user-service/user.v1.proto"
32///   fingerprint = "semantic:abc123..."
33/// ```
34#[derive(Debug, Clone, Serialize, Deserialize, Default)]
35pub struct LockFile {
36    /// Lock file metadata
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub metadata: Option<LockMetadata>,
39
40    /// Locked dependencies (ordered array for deterministic output)
41    #[serde(rename = "dependency", default, skip_serializing_if = "Vec::is_empty")]
42    pub dependencies: Vec<LockedDependency>,
43}
44
45/// Lock file metadata
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct LockMetadata {
48    /// Lock file format version
49    pub version: u32,
50    /// Generation timestamp (ISO 8601)
51    pub generated_at: String,
52}
53
54/// A locked dependency entry
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct LockedDependency {
57    /// Service name (identifier in lock file, matches Actr.toml dependency name property)
58    pub name: String,
59
60    /// Actor type (e.g., "acme+user-service")
61    pub actr_type: String,
62
63    /// Service description
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub description: Option<String>,
66
67    /// Service-level semantic fingerprint (format: "service_semantic:hash")
68    pub fingerprint: String,
69
70    /// Publication timestamp (Unix epoch seconds)
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub published_at: Option<i64>,
73
74    /// Tags like "latest", "stable"
75    #[serde(default, skip_serializing_if = "Vec::is_empty")]
76    pub tags: Vec<String>,
77
78    /// When this dependency was cached (ISO 8601)
79    pub cached_at: String,
80
81    /// Proto file references (path + fingerprint only, no content)
82    #[serde(rename = "files")]
83    pub files: Vec<LockedProtoFile>,
84}
85
86/// Proto file reference in lock file (NO content, just path and fingerprint)
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct LockedProtoFile {
89    /// Relative path from project's proto/ folder (e.g., "user-service/user.v1.proto")
90    pub path: String,
91
92    /// Semantic fingerprint of the file (format: "semantic:hash")
93    pub fingerprint: String,
94}
95
96/// Service specification metadata (for backward compatibility and conversions)
97/// Holds proto file references for conversions
98#[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    /// Service-level semantic fingerprint
106    pub fingerprint: String,
107
108    /// Proto files referenced by path
109    #[serde(rename = "files")]
110    pub protobufs: Vec<ProtoFileMeta>,
111
112    /// Publication timestamp (Unix epoch seconds)
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub published_at: Option<i64>,
115
116    /// Tags like "latest", "stable"
117    #[serde(default, skip_serializing_if = "Vec::is_empty")]
118    pub tags: Vec<String>,
119}
120
121/// Package-level protobuf entry in lock file
122/// Note: References a local file instead of embedding content
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct ProtoFileMeta {
125    /// Relative path to the proto file (e.g., "remote/user-service/user.v1.proto")
126    pub path: String,
127
128    /// Semantic fingerprint of the package content
129    pub fingerprint: String,
130}
131
132// ============================================================================
133// Bidirectional Conversion: ServiceSpec ↔ ServiceSpecMeta
134// ============================================================================
135
136impl 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(), // Content is no longer in lock file
168                    fingerprint: proto.fingerprint,
169                })
170                .collect(),
171            published_at: meta.published_at,
172            tags: meta.tags,
173        }
174    }
175}
176
177// ============================================================================
178// LockFile Operations
179// ============================================================================
180
181impl LockFile {
182    /// Create a new empty lock file with current timestamp
183    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    /// Load lock file from disk
194    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    /// Save lock file to disk
200    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    /// Add or update a dependency
207    pub fn add_dependency(&mut self, dep: LockedDependency) {
208        // Remove existing entry with same name if exists
209        self.dependencies.retain(|d| d.name != dep.name);
210
211        // Add new entry
212        self.dependencies.push(dep);
213
214        // Sort by name for deterministic output
215        self.dependencies.sort_by(|a, b| a.name.cmp(&b.name));
216    }
217
218    /// Get a dependency by name
219    pub fn get_dependency(&self, name: &str) -> Option<&LockedDependency> {
220        self.dependencies.iter().find(|d| d.name == name)
221    }
222
223    /// Remove a dependency by name
224    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    /// Update generation timestamp
231    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    /// Create a new locked dependency entry
248    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    /// Get service-level fingerprint
269    pub fn service_fingerprint(&self) -> &str {
270        &self.fingerprint
271    }
272
273    /// Get file fingerprints
274    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// ============================================================================
287// Tests
288// ============================================================================
289
290#[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        // Convert to meta
310        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        // Convert back to ServiceSpec
318        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        // Note: content is lost during conversion back to ServiceSpec
323        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        // Serialize to TOML
375        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        // Lock file should NOT contain proto content anymore
379        assert!(!toml_str.contains("syntax = \"proto3\""));
380        assert!(toml_str.contains("service_semantic:abc123"));
381
382        // Deserialize back
383        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        // Serialize
411        let toml_str = toml::to_string_pretty(&spec_meta).unwrap();
412
413        // Should contain path
414        assert!(toml_str.contains("path = \"user-service/user.v1.proto\""));
415
416        // Deserialize back
417        let restored: ServiceSpecMeta = toml::from_str(&toml_str).unwrap();
418        assert_eq!(restored.protobufs[0].path, "user-service/user.v1.proto");
419    }
420}