use crate::marketplace::trust::TrustTier;
use crate::utils::error::{Error, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Lockfile {
pub version: u64,
pub packs: Vec<LockfileEntry>,
pub bundles: Vec<BundleExpansion>,
pub profile: ProfileRef,
pub digest: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockfileEntry {
pub pack_id: String,
pub version: String,
pub source: RegistrySource,
pub digest: String,
pub signature: String,
pub trust_tier: TrustTier,
pub dependencies: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BundleExpansion {
pub bundle_id: String,
pub expanded_to: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum RegistrySource {
Local { path: PathBuf },
Registry { url: String },
Cache { path: PathBuf },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileRef {
pub profile_id: String,
pub runtime_constraints: Vec<String>,
pub trust_requirement: TrustTier,
}
impl Lockfile {
pub fn new(profile: ProfileRef) -> Self {
let lockfile = Self {
version: 1,
packs: Vec::new(),
bundles: Vec::new(),
profile,
digest: String::new(), signature: None,
};
lockfile.with_computed_digest()
}
fn compute_digest(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(self.version.to_string().as_bytes());
for pack in &self.packs {
hasher.update(pack.pack_id.as_bytes());
hasher.update(b":");
hasher.update(pack.version.as_bytes());
hasher.update(b":");
hasher.update(pack.digest.as_bytes());
hasher.update(b"\n");
}
for bundle in &self.bundles {
hasher.update(bundle.bundle_id.as_bytes());
hasher.update(b":");
for expanded in &bundle.expanded_to {
hasher.update(expanded.as_bytes());
hasher.update(b",");
}
hasher.update(b"\n");
}
hasher.update(self.profile.profile_id.as_bytes());
format!("{:x}", hasher.finalize())
}
fn with_computed_digest(mut self) -> Self {
self.digest = self.compute_digest();
self
}
pub fn add_pack(&mut self, entry: LockfileEntry) {
self.packs.retain(|p| p.pack_id != entry.pack_id);
self.packs.push(entry);
}
pub fn add_bundle(&mut self, expansion: BundleExpansion) {
self.bundles.retain(|b| b.bundle_id != expansion.bundle_id);
self.bundles.push(expansion);
}
pub fn save(&self, project_root: &Path) -> Result<()> {
let lockfile_path = project_root.join(".ggen").join("packs.lock");
if let Some(parent) = lockfile_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
Error::new(&format!(
"Failed to create lockfile directory '{}': {}",
parent.display(),
e
))
})?;
}
let lockfile_with_digest = self.clone().with_computed_digest();
let json = serde_json::to_string_pretty(&lockfile_with_digest)
.map_err(|e| Error::new(&format!("Failed to serialize lockfile: {}", e)))?;
fs::write(&lockfile_path, json).map_err(|e| {
Error::new(&format!(
"Failed to write lockfile to '{}': {}",
lockfile_path.display(),
e
))
})?;
Ok(())
}
pub fn load(project_root: &Path) -> Result<Option<Self>> {
let lockfile_path = project_root.join(".ggen").join("packs.lock");
if !lockfile_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&lockfile_path).map_err(|e| {
Error::new(&format!(
"Failed to read lockfile from '{}': {}",
lockfile_path.display(),
e
))
})?;
let lockfile: Lockfile = serde_json::from_str(&content)
.map_err(|e| Error::new(&format!("Failed to parse lockfile: {}", e)))?;
let computed_digest = lockfile.compute_digest();
if computed_digest != lockfile.digest {
return Err(Error::new(&format!(
"Lockfile digest mismatch: expected {}, got {}",
lockfile.digest, computed_digest
)));
}
Ok(Some(lockfile))
}
pub fn verify(&self) -> Result<bool> {
let computed = self.compute_digest();
Ok(computed == self.digest)
}
pub fn get_pack(&self, pack_id: &str) -> Option<&LockfileEntry> {
self.packs.iter().find(|p| p.pack_id == pack_id)
}
pub fn pack_ids(&self) -> Vec<String> {
self.packs.iter().map(|p| p.pack_id.clone()).collect()
}
}
impl LockfileEntry {
pub fn new(
pack_id: String, version: String, source: RegistrySource, digest: String,
signature: String, trust_tier: TrustTier, dependencies: Vec<String>,
) -> Self {
Self {
pack_id,
version,
source,
digest,
signature,
trust_tier,
dependencies,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_profile() -> ProfileRef {
ProfileRef {
profile_id: "enterprise-strict".to_string(),
runtime_constraints: vec!["require-explicit-runtime".to_string()],
trust_requirement: TrustTier::EnterpriseCertified,
}
}
#[test]
fn test_lockfile_creation() {
let profile = create_test_profile();
let lockfile = Lockfile::new(profile);
assert_eq!(lockfile.version, 1);
assert_eq!(lockfile.packs.len(), 0);
assert_eq!(lockfile.bundles.len(), 0);
assert!(!lockfile.digest.is_empty());
}
#[test]
fn test_lockfile_add_pack() {
let profile = create_test_profile();
let mut lockfile = Lockfile::new(profile);
let entry = LockfileEntry::new(
"surface-mcp".to_string(),
"1.0.0".to_string(),
RegistrySource::Registry {
url: "https://registry.ggen.io".to_string(),
},
"sha256:abc123".to_string(),
"ed25519:def456".to_string(),
TrustTier::EnterpriseCertified,
vec!["core-ontology".to_string()],
);
lockfile.add_pack(entry);
assert_eq!(lockfile.packs.len(), 1);
assert_eq!(lockfile.packs[0].pack_id, "surface-mcp");
}
#[test]
fn test_lockfile_add_bundle() {
let profile = create_test_profile();
let mut lockfile = Lockfile::new(profile);
let expansion = BundleExpansion {
bundle_id: "mcp-rust".to_string(),
expanded_to: vec![
"surface-mcp".to_string(),
"projection-rust".to_string(),
"runtime-axum".to_string(),
],
};
lockfile.add_bundle(expansion);
assert_eq!(lockfile.bundles.len(), 1);
assert_eq!(lockfile.bundles[0].bundle_id, "mcp-rust");
}
#[test]
fn test_lockfile_save_and_load() {
let temp_dir = TempDir::new().unwrap();
let profile = create_test_profile();
let mut lockfile = Lockfile::new(profile.clone());
let entry = LockfileEntry::new(
"surface-mcp".to_string(),
"1.0.0".to_string(),
RegistrySource::Local {
path: PathBuf::from("/tmp/pack"),
},
"sha256:abc123".to_string(),
"sig".to_string(),
TrustTier::EnterpriseCertified,
vec![],
);
lockfile.add_pack(entry);
lockfile.save(temp_dir.path()).unwrap();
let loaded = Lockfile::load(temp_dir.path()).unwrap().unwrap();
assert_eq!(loaded.packs.len(), 1);
assert_eq!(loaded.packs[0].pack_id, "surface-mcp");
assert_eq!(loaded.profile.profile_id, "enterprise-strict");
}
#[test]
fn test_lockfile_verify() {
let profile = create_test_profile();
let lockfile = Lockfile::new(profile);
assert!(lockfile.verify().unwrap());
}
#[test]
fn test_lockfile_get_pack() {
let profile = create_test_profile();
let mut lockfile = Lockfile::new(profile);
let entry = LockfileEntry::new(
"surface-mcp".to_string(),
"1.0.0".to_string(),
RegistrySource::Registry {
url: "https://registry.ggen.io".to_string(),
},
"sha256:abc123".to_string(),
"sig".to_string(),
TrustTier::EnterpriseCertified,
vec![],
);
lockfile.add_pack(entry);
let retrieved = lockfile.get_pack("surface-mcp");
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().version, "1.0.0");
let missing = lockfile.get_pack("nonexistent");
assert!(missing.is_none());
}
#[test]
fn test_lockfile_digest_changes_with_packs() {
let profile = create_test_profile();
let lockfile1 = Lockfile::new(profile.clone());
let digest1 = lockfile1.digest.clone();
let mut lockfile2 = Lockfile::new(profile);
lockfile2.add_pack(LockfileEntry::new(
"surface-mcp".to_string(),
"1.0.0".to_string(),
RegistrySource::Registry {
url: "https://registry.ggen.io".to_string(),
},
"sha256:abc123".to_string(),
"sig".to_string(),
TrustTier::EnterpriseCertified,
vec![],
));
let lockfile2 = lockfile2.with_computed_digest();
let digest2 = lockfile2.digest;
assert_ne!(digest1, digest2);
}
}