ggen_core/lockfile_unified/
entry.rs

1//! Unified lockfile entry types supporting all use cases
2//!
3//! This module provides a unified entry type that can represent entries
4//! from any of the three existing lockfile implementations.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9use std::path::PathBuf;
10
11use super::traits::LockEntry;
12
13/// Unified lock entry supporting packs, ontologies, and gpacks
14///
15/// This type consolidates the features of:
16/// - `LockEntry` from `lockfile.rs` (PQC signatures)
17/// - `LockedPackage` from `lock_manager.rs` (ontology metadata)
18/// - `LockedPack` from `packs/lockfile.rs` (source tracking)
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
20pub struct UnifiedLockEntry {
21    /// Entry identifier
22    #[serde(skip)]
23    id: String,
24
25    /// Version string (semver or custom)
26    pub version: String,
27
28    /// Integrity hash (SHA256, lowercase hex, 64 chars)
29    pub integrity: String,
30
31    /// Source location
32    pub source: LockSource,
33
34    /// When this entry was added/updated
35    pub locked_at: DateTime<Utc>,
36
37    /// Dependencies (list of entry IDs)
38    #[serde(default, skip_serializing_if = "Vec::is_empty")]
39    pub dependencies: Vec<String>,
40
41    /// Extended metadata (domain-specific)
42    #[serde(default, skip_serializing_if = "ExtendedMetadata::is_empty")]
43    pub metadata: ExtendedMetadata,
44
45    /// PQC signature (optional)
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub pqc: Option<PqcSignature>,
48}
49
50impl UnifiedLockEntry {
51    /// Create a new unified lock entry
52    pub fn new(
53        id: impl Into<String>, version: impl Into<String>, integrity: impl Into<String>,
54        source: LockSource,
55    ) -> Self {
56        Self {
57            id: id.into(),
58            version: version.into(),
59            integrity: integrity.into(),
60            source,
61            locked_at: Utc::now(),
62            dependencies: Vec::new(),
63            metadata: ExtendedMetadata::default(),
64            pqc: None,
65        }
66    }
67
68    /// Set entry ID (used when loading from map)
69    pub fn with_id(mut self, id: impl Into<String>) -> Self {
70        self.id = id.into();
71        self
72    }
73
74    /// Add dependencies
75    pub fn with_dependencies(mut self, deps: Vec<String>) -> Self {
76        self.dependencies = deps;
77        self
78    }
79
80    /// Add PQC signature
81    pub fn with_pqc(mut self, pqc: PqcSignature) -> Self {
82        self.pqc = Some(pqc);
83        self
84    }
85
86    /// Add extended metadata
87    pub fn with_metadata(mut self, metadata: ExtendedMetadata) -> Self {
88        self.metadata = metadata;
89        self
90    }
91}
92
93impl LockEntry for UnifiedLockEntry {
94    fn id(&self) -> &str {
95        &self.id
96    }
97
98    fn version(&self) -> &str {
99        &self.version
100    }
101
102    fn integrity(&self) -> Option<&str> {
103        Some(&self.integrity)
104    }
105
106    fn dependencies(&self) -> &[String] {
107        &self.dependencies
108    }
109}
110
111/// Source of a locked entry
112#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
113#[serde(tag = "type")]
114pub enum LockSource {
115    /// Registry (marketplace)
116    Registry {
117        /// Registry URL
118        url: String,
119        /// Resolved download URL (optional)
120        #[serde(skip_serializing_if = "Option::is_none")]
121        resolved: Option<String>,
122    },
123
124    /// Git repository
125    Git {
126        /// Repository URL
127        url: String,
128        /// Branch name
129        #[serde(skip_serializing_if = "Option::is_none")]
130        branch: Option<String>,
131        /// Tag
132        #[serde(skip_serializing_if = "Option::is_none")]
133        tag: Option<String>,
134        /// Commit SHA
135        #[serde(skip_serializing_if = "Option::is_none")]
136        commit: Option<String>,
137    },
138
139    /// GitHub shorthand
140    GitHub {
141        /// Organization or user
142        org: String,
143        /// Repository name
144        repo: String,
145        /// Branch or tag
146        branch: String,
147    },
148
149    /// Local filesystem
150    Local {
151        /// Path to pack directory
152        path: PathBuf,
153    },
154}
155
156impl std::fmt::Display for LockSource {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        match self {
159            LockSource::Registry { url, .. } => write!(f, "registry+{}", url),
160            LockSource::Git { url, branch, .. } => {
161                if let Some(b) = branch {
162                    write!(f, "git+{}#{}", url, b)
163                } else {
164                    write!(f, "git+{}", url)
165                }
166            }
167            LockSource::GitHub { org, repo, branch } => {
168                write!(f, "github:{}/{}#{}", org, repo, branch)
169            }
170            LockSource::Local { path } => write!(f, "path:{}", path.display()),
171        }
172    }
173}
174
175/// PQC signature data
176#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
177pub struct PqcSignature {
178    /// Algorithm identifier (e.g., "ML-DSA-65", "Dilithium3")
179    pub algorithm: String,
180    /// Base64-encoded signature
181    pub signature: String,
182    /// Base64-encoded public key
183    pub pubkey: String,
184}
185
186impl PqcSignature {
187    /// Create a new PQC signature
188    pub fn new(
189        algorithm: impl Into<String>, signature: impl Into<String>, pubkey: impl Into<String>,
190    ) -> Self {
191        Self {
192            algorithm: algorithm.into(),
193            signature: signature.into(),
194            pubkey: pubkey.into(),
195        }
196    }
197
198    /// Create ML-DSA-65 signature
199    pub fn ml_dsa_65(signature: impl Into<String>, pubkey: impl Into<String>) -> Self {
200        Self::new("ML-DSA-65", signature, pubkey)
201    }
202}
203
204/// Extended metadata for domain-specific needs
205#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
206pub struct ExtendedMetadata {
207    /// For ontology packs: namespace URI
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub namespace: Option<String>,
210
211    /// For ontology packs: class count
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub classes_count: Option<usize>,
214
215    /// For ontology packs: property count
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub properties_count: Option<usize>,
218
219    /// For composition: strategy used
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub composition_strategy: Option<String>,
222
223    /// Installation timestamp (if different from locked_at)
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub installed_at: Option<DateTime<Utc>>,
226
227    /// Custom key-value pairs for extensibility
228    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
229    pub custom: BTreeMap<String, String>,
230}
231
232impl ExtendedMetadata {
233    /// Check if metadata is empty (for serialization skip)
234    pub fn is_empty(&self) -> bool {
235        self.namespace.is_none()
236            && self.classes_count.is_none()
237            && self.properties_count.is_none()
238            && self.composition_strategy.is_none()
239            && self.installed_at.is_none()
240            && self.custom.is_empty()
241    }
242
243    /// Create ontology-specific metadata
244    pub fn ontology(namespace: impl Into<String>, classes: usize, properties: usize) -> Self {
245        Self {
246            namespace: Some(namespace.into()),
247            classes_count: Some(classes),
248            properties_count: Some(properties),
249            ..Default::default()
250        }
251    }
252}
253
254// ============================================================================
255// Backward-Compatible Conversions
256// ============================================================================
257
258/// Convert from existing LockEntry (lockfile.rs)
259impl From<crate::lockfile::LockEntry> for UnifiedLockEntry {
260    fn from(entry: crate::lockfile::LockEntry) -> Self {
261        Self {
262            id: entry.id.clone(),
263            version: entry.version,
264            integrity: entry.sha256,
265            source: LockSource::Git {
266                url: entry.source,
267                branch: None,
268                tag: None,
269                commit: None,
270            },
271            locked_at: Utc::now(),
272            dependencies: entry.dependencies.unwrap_or_default(),
273            metadata: ExtendedMetadata::default(),
274            pqc: entry.pqc_signature.map(|sig| PqcSignature {
275                algorithm: "Dilithium3".to_string(),
276                signature: sig,
277                pubkey: entry.pqc_pubkey.unwrap_or_default(),
278            }),
279        }
280    }
281}
282
283/// Convert from PackLockfile's LockedPack (packs/lockfile.rs)
284impl From<crate::packs::lockfile::LockedPack> for UnifiedLockEntry {
285    fn from(pack: crate::packs::lockfile::LockedPack) -> Self {
286        Self {
287            id: String::new(), // Set via with_id()
288            version: pack.version,
289            integrity: pack.integrity.unwrap_or_default(),
290            source: match pack.source {
291                crate::packs::lockfile::PackSource::Registry { url } => LockSource::Registry {
292                    url,
293                    resolved: None,
294                },
295                crate::packs::lockfile::PackSource::GitHub { org, repo, branch } => {
296                    LockSource::GitHub { org, repo, branch }
297                }
298                crate::packs::lockfile::PackSource::Local { path } => LockSource::Local { path },
299            },
300            locked_at: pack.installed_at,
301            dependencies: pack.dependencies,
302            metadata: ExtendedMetadata {
303                installed_at: Some(pack.installed_at),
304                ..Default::default()
305            },
306            pqc: None,
307        }
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn test_unified_entry_creation() {
317        let entry = UnifiedLockEntry::new(
318            "io.ggen.test",
319            "1.0.0",
320            "abc123def456",
321            LockSource::Registry {
322                url: "https://registry.ggen.io".into(),
323                resolved: None,
324            },
325        );
326
327        assert_eq!(entry.version, "1.0.0");
328        assert_eq!(entry.integrity, "abc123def456");
329    }
330
331    #[test]
332    fn test_lock_source_display() {
333        let registry = LockSource::Registry {
334            url: "https://registry.ggen.io".into(),
335            resolved: None,
336        };
337        assert_eq!(registry.to_string(), "registry+https://registry.ggen.io");
338
339        let github = LockSource::GitHub {
340            org: "seanchatmangpt".into(),
341            repo: "ggen".into(),
342            branch: "main".into(),
343        };
344        assert_eq!(github.to_string(), "github:seanchatmangpt/ggen#main");
345    }
346
347    #[test]
348    fn test_extended_metadata_is_empty() {
349        let empty = ExtendedMetadata::default();
350        assert!(empty.is_empty());
351
352        let with_namespace = ExtendedMetadata {
353            namespace: Some("https://schema.org/".into()),
354            ..Default::default()
355        };
356        assert!(!with_namespace.is_empty());
357    }
358
359    #[test]
360    fn test_pqc_signature_creation() {
361        let sig = PqcSignature::ml_dsa_65("base64sig", "base64pubkey");
362        assert_eq!(sig.algorithm, "ML-DSA-65");
363    }
364}