use crate::utils::error::{Error, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PackLockfile {
pub packs: BTreeMap<String, LockedPack>,
pub updated_at: DateTime<Utc>,
pub ggen_version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LockedPack {
pub version: String,
pub source: PackSource,
#[serde(skip_serializing_if = "Option::is_none")]
pub integrity: Option<String>,
pub installed_at: DateTime<Utc>,
#[serde(default)]
pub dependencies: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type")]
pub enum PackSource {
Registry {
url: String,
},
GitHub {
org: String,
repo: String,
branch: String,
},
Local {
path: PathBuf,
},
}
impl PackLockfile {
pub fn new(ggen_version: impl Into<String>) -> Self {
Self {
packs: BTreeMap::new(),
updated_at: Utc::now(),
ggen_version: ggen_version.into(),
profile: None,
}
}
pub fn from_file(path: &Path) -> Result<Self> {
if !path.exists() {
return Err(Error::new(&format!(
"Lockfile not found at path: {}",
path.display()
)));
}
let content = fs::read_to_string(path).map_err(|e| {
Error::with_context(
"Failed to read lockfile",
&format!("{}: {}", path.display(), e),
)
})?;
let lockfile: PackLockfile = serde_json::from_str(&content).map_err(|e| {
Error::with_context(
"Failed to parse lockfile JSON",
&format!("{}: {}", path.display(), e),
)
})?;
lockfile.validate()?;
Ok(lockfile)
}
pub fn save(&self, path: &Path) -> Result<()> {
self.validate()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
Error::with_context(
"Failed to create lockfile directory",
&format!("{}: {}", parent.display(), e),
)
})?;
}
let json = serde_json::to_string_pretty(self)
.map_err(|e| Error::with_context("Failed to serialize lockfile", &e.to_string()))?;
fs::write(path, json).map_err(|e| {
Error::with_context(
"Failed to write lockfile",
&format!("{}: {}", path.display(), e),
)
})?;
Ok(())
}
pub fn get_pack(&self, pack_id: &str) -> Option<&LockedPack> {
self.packs.get(pack_id)
}
pub fn add_pack(&mut self, pack_id: impl Into<String>, pack: LockedPack) {
self.packs.insert(pack_id.into(), pack);
self.updated_at = Utc::now();
}
pub fn remove_pack(&mut self, pack_id: &str) -> bool {
let removed = self.packs.remove(pack_id).is_some();
if removed {
self.updated_at = Utc::now();
}
removed
}
pub fn validate(&self) -> Result<()> {
for (pack_id, pack) in &self.packs {
for dep_id in &pack.dependencies {
if !self.packs.contains_key(dep_id) {
return Err(Error::new(&format!(
"Pack '{}' depends on '{}' which is not in lockfile",
pack_id, dep_id
)));
}
}
}
for pack_id in self.packs.keys() {
self.check_circular_deps(pack_id, pack_id, &mut Vec::new())?;
}
Ok(())
}
pub fn validate_invariants(&self) -> Result<()> {
for (pack_id, pack) in &self.packs {
if pack.version.trim().is_empty() {
return Err(Error::new(&format!(
"Lockfile invariant violation: pack '{}' has an empty version",
pack_id
)));
}
if pack.installed_at.timestamp() <= 0 {
return Err(Error::new(&format!(
"Lockfile invariant violation: pack '{}' has a non-real installed_at \
timestamp ({}); expected a populated timestamp, not the Unix epoch / default",
pack_id, pack.installed_at
)));
}
match &pack.integrity {
None => {
return Err(Error::new(&format!(
"Lockfile invariant violation: pack '{}' is missing an integrity digest",
pack_id
)));
}
Some(integrity) => {
if !is_valid_sha256_integrity(integrity) {
return Err(Error::new(&format!(
"Lockfile invariant violation: pack '{}' has a malformed integrity \
digest '{}'; expected the shape 'sha256-<64 hex chars>'",
pack_id, integrity
)));
}
}
}
}
Ok(())
}
fn check_circular_deps(
&self, start_pack: &str, current_pack: &str, visited: &mut Vec<String>,
) -> Result<()> {
if visited.contains(¤t_pack.to_string()) {
if current_pack == start_pack {
return Err(Error::new(&format!(
"Circular dependency detected: {} -> {}",
visited.join(" -> "),
current_pack
)));
}
return Ok(());
}
visited.push(current_pack.to_string());
if let Some(pack) = self.packs.get(current_pack) {
for dep_id in &pack.dependencies {
self.check_circular_deps(start_pack, dep_id, visited)?;
}
}
visited.pop();
Ok(())
}
}
fn is_valid_sha256_integrity(integrity: &str) -> bool {
const PREFIX: &str = "sha256-";
match integrity.strip_prefix(PREFIX) {
Some(hash) => hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()),
None => false,
}
}
impl fmt::Display for PackLockfile {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "Pack Lockfile (ggen v{})", self.ggen_version)?;
writeln!(
f,
"Updated: {}",
self.updated_at.format("%Y-%m-%d %H:%M:%S UTC")
)?;
writeln!(f, "Packs: {}", self.packs.len())?;
writeln!(f)?;
for (pack_id, pack) in &self.packs {
writeln!(f, " {} @ {}", pack_id, pack.version)?;
writeln!(f, " Source: {}", pack.source)?;
if let Some(integrity) = &pack.integrity {
writeln!(f, " Integrity: {}", integrity)?;
}
if !pack.dependencies.is_empty() {
writeln!(f, " Dependencies: {}", pack.dependencies.join(", "))?;
}
}
Ok(())
}
}
impl fmt::Display for PackSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PackSource::Registry { url } => write!(f, "Registry({})", url),
PackSource::GitHub { org, repo, branch } => {
write!(f, "GitHub({}/{}@{})", org, repo, branch)
}
PackSource::Local { path } => write!(f, "Local({})", path.display()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_pack(version: &str, deps: Vec<String>) -> LockedPack {
LockedPack {
version: version.to_string(),
source: PackSource::Registry {
url: "https://registry.ggen.io".to_string(),
},
integrity: Some("sha256-test".to_string()),
installed_at: Utc::now(),
dependencies: deps,
}
}
#[test]
fn test_new_lockfile() {
let lockfile = PackLockfile::new("4.0.0");
assert_eq!(lockfile.packs.len(), 0);
assert_eq!(lockfile.ggen_version, "4.0.0");
}
#[test]
fn test_add_and_get_pack() {
let mut lockfile = PackLockfile::new("4.0.0");
let pack = create_test_pack("1.0.0", vec![]);
lockfile.add_pack("test.pack", pack.clone());
let retrieved = lockfile.get_pack("test.pack");
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().version, "1.0.0");
}
#[test]
fn test_remove_pack() {
let mut lockfile = PackLockfile::new("4.0.0");
let pack = create_test_pack("1.0.0", vec![]);
lockfile.add_pack("test.pack", pack);
assert!(lockfile.get_pack("test.pack").is_some());
let removed = lockfile.remove_pack("test.pack");
assert!(removed);
assert!(lockfile.get_pack("test.pack").is_none());
let removed_again = lockfile.remove_pack("test.pack");
assert!(!removed_again);
}
#[test]
fn test_pack_source_display() {
let registry = PackSource::Registry {
url: "https://registry.ggen.io".to_string(),
};
assert_eq!(registry.to_string(), "Registry(https://registry.ggen.io)");
let github = PackSource::GitHub {
org: "seanchatmangpt".to_string(),
repo: "ggen".to_string(),
branch: "main".to_string(),
};
assert_eq!(github.to_string(), "GitHub(seanchatmangpt/ggen@main)");
let local = PackSource::Local {
path: PathBuf::from("/tmp/pack"),
};
assert_eq!(local.to_string(), "Local(/tmp/pack)");
}
fn valid_integrity() -> String {
format!("sha256-{}", "a".repeat(64))
}
fn valid_invariant_pack() -> LockedPack {
LockedPack {
version: "1.0.0".to_string(),
source: PackSource::Registry {
url: "https://registry.ggen.io".to_string(),
},
integrity: Some(valid_integrity()),
installed_at: Utc::now(),
dependencies: vec![],
}
}
#[test]
fn test_validate_invariants_accepts_valid_lockfile() {
use tempfile::TempDir;
let temp_dir = TempDir::new().expect("create temp dir");
let lock_path = temp_dir.path().join("packs.lock");
let mut lockfile = PackLockfile::new("4.0.0");
lockfile.add_pack("io.ggen.rust.cli", valid_invariant_pack());
assert!(
lockfile.validate_invariants().is_ok(),
"valid lockfile should pass §4.1 invariants"
);
lockfile.save(&lock_path).expect("save lockfile");
assert!(lock_path.exists(), "lockfile should exist on disk");
let loaded = PackLockfile::from_file(&lock_path).expect("load lockfile");
assert!(
loaded.validate_invariants().is_ok(),
"lockfile loaded from disk should still pass §4.1 invariants"
);
}
#[test]
fn test_validate_invariants_rejects_empty_digest() {
use tempfile::TempDir;
let temp_dir = TempDir::new().expect("create temp dir");
let lock_path = temp_dir.path().join("packs.lock");
let mut pack = valid_invariant_pack();
pack.integrity = Some(String::new());
let mut lockfile = PackLockfile::new("4.0.0");
lockfile.add_pack("io.ggen.rust.cli", pack);
lockfile.save(&lock_path).expect("save lockfile");
let loaded = PackLockfile::from_file(&lock_path).expect("load lockfile");
let result = loaded.validate_invariants();
assert!(result.is_err(), "empty integrity digest must fail §4.1");
let msg = result.expect_err("expected an error").to_string();
assert!(
msg.contains("malformed integrity"),
"error should name the malformed integrity digest, got: {msg}"
);
}
#[test]
fn test_validate_invariants_rejects_malformed_digest() {
use tempfile::TempDir;
let temp_dir = TempDir::new().expect("create temp dir");
{
let lock_path = temp_dir.path().join("short.lock");
let mut pack = valid_invariant_pack();
pack.integrity = Some("sha256-abc123".to_string());
let mut lockfile = PackLockfile::new("4.0.0");
lockfile.add_pack("io.ggen.rust.cli", pack);
lockfile.save(&lock_path).expect("save lockfile");
let loaded = PackLockfile::from_file(&lock_path).expect("load lockfile");
assert!(
loaded.validate_invariants().is_err(),
"too-short integrity digest must fail §4.1"
);
}
{
let lock_path = temp_dir.path().join("noprefix.lock");
let mut pack = valid_invariant_pack();
pack.integrity = Some("a".repeat(64));
let mut lockfile = PackLockfile::new("4.0.0");
lockfile.add_pack("io.ggen.rust.cli", pack);
lockfile.save(&lock_path).expect("save lockfile");
let loaded = PackLockfile::from_file(&lock_path).expect("load lockfile");
assert!(
loaded.validate_invariants().is_err(),
"integrity digest without sha256- prefix must fail §4.1"
);
}
{
let mut pack = valid_invariant_pack();
pack.integrity = Some(format!("sha256-{}", "z".repeat(64)));
let mut lockfile = PackLockfile::new("4.0.0");
lockfile.add_pack("io.ggen.rust.cli", pack);
assert!(
lockfile.validate_invariants().is_err(),
"integrity digest with non-hex body must fail §4.1"
);
}
}
#[test]
fn test_validate_invariants_rejects_empty_version() {
use tempfile::TempDir;
let temp_dir = TempDir::new().expect("create temp dir");
let lock_path = temp_dir.path().join("packs.lock");
let mut pack = valid_invariant_pack();
pack.version = String::new();
let mut lockfile = PackLockfile::new("4.0.0");
lockfile.add_pack("io.ggen.rust.cli", pack);
lockfile.save(&lock_path).expect("save lockfile");
let loaded = PackLockfile::from_file(&lock_path).expect("load lockfile");
let result = loaded.validate_invariants();
assert!(result.is_err(), "empty version must fail §4.1");
let msg = result.expect_err("expected an error").to_string();
assert!(
msg.contains("empty version"),
"error should name the empty version, got: {msg}"
);
}
#[test]
fn test_validate_invariants_rejects_epoch_installed_at() {
let mut pack = valid_invariant_pack();
pack.installed_at = DateTime::<Utc>::default();
let mut lockfile = PackLockfile::new("4.0.0");
lockfile.add_pack("io.ggen.rust.cli", pack);
let result = lockfile.validate_invariants();
assert!(result.is_err(), "epoch/default installed_at must fail §4.1");
let msg = result.expect_err("expected an error").to_string();
assert!(
msg.contains("installed_at"),
"error should name installed_at, got: {msg}"
);
}
}