ggen-config 26.7.2

Configuration parser and validator for ggen.toml files
Documentation
//! Lock file management for reproducible ontology builds
//!
//! Implements ggen.lock pattern for freezing ontology pack versions,
//! enabling reproducible code generation across environments.

use crate::config_lib::error::Result;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::Path;

/// Lock file structure (ggen.lock)
///
/// Freezes exact versions of ontology packs for reproducible builds.
/// Similar to package-lock.json in npm or Cargo.lock in Rust.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OntologyLockfile {
    /// Lockfile format version
    pub version: u32,

    /// Generated timestamp (ISO 8601)
    pub generated_at: String,

    /// Tool version that generated this lock
    pub generator_version: String,

    /// Locked ontology pack versions
    pub packages: BTreeMap<String, LockedPackage>,

    /// Composition metadata
    pub composition: CompositionMetadata,

    /// Integrity hashes for validation
    pub hashes: BTreeMap<String, String>,
}

/// A locked ontology pack entry
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockedPackage {
    /// Exact resolved version
    pub version: String,

    /// Registry resolution URL
    pub resolved: String,

    /// Package integrity hash (SHA256)
    pub integrity: String,

    /// Installed location
    pub location: String,

    /// Extracted namespace
    pub namespace: Option<String>,

    /// Class count in this version
    pub classes_count: usize,

    /// Property count in this version
    pub properties_count: usize,

    /// Dependencies of this pack (other packs it requires)
    pub dependencies: BTreeMap<String, String>,

    /// Installation timestamp
    pub installed_at: String,
}

/// Metadata about composition
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompositionMetadata {
    /// Strategy used for composition
    pub strategy: String,

    /// Total classes after composition
    pub total_classes: usize,

    /// Total properties after composition
    pub total_properties: usize,

    /// Conflict resolutions applied
    pub conflicts_resolved: usize,

    /// Validation status
    pub validation_status: String,
}

/// Lock file manager
pub struct LockfileManager;

impl LockfileManager {
    /// Create a new lockfile manager (for API compatibility)
    pub fn new(_output_dir: &Path) -> Self {
        Self
    }
    /// Create a new lockfile from current configuration
    pub fn create(
        packages: BTreeMap<String, LockedPackage>, composition: CompositionMetadata,
    ) -> Result<OntologyLockfile> {
        let lockfile = OntologyLockfile {
            version: 1,
            generated_at: chrono::Utc::now().to_rfc3339(),
            generator_version: env!("CARGO_PKG_VERSION").to_string(),
            hashes: Self::compute_hashes(&packages),
            packages,
            composition,
        };

        lockfile.validate()?;
        Ok(lockfile)
    }

    /// Load lockfile from disk
    pub fn load(path: &Path) -> Result<OntologyLockfile> {
        let content = std::fs::read_to_string(path).map_err(|e| {
            crate::config_lib::ConfigError::Validation(format!("Failed to read lock file: {}", e))
        })?;

        let lockfile: OntologyLockfile = toml::from_str(&content).map_err(|e| {
            crate::config_lib::ConfigError::Validation(format!("Failed to parse lock file: {}", e))
        })?;

        lockfile.validate()?;
        Ok(lockfile)
    }

    /// Save lockfile to disk
    pub fn save(lockfile: &OntologyLockfile, path: &Path) -> Result<()> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).map_err(|e| {
                crate::config_lib::ConfigError::Validation(format!(
                    "Failed to create lock file directory: {}",
                    e
                ))
            })?;
        }

        let content = toml::to_string_pretty(lockfile).map_err(|e| {
            crate::config_lib::ConfigError::Validation(format!(
                "Failed to serialize lock file: {}",
                e
            ))
        })?;

        std::fs::write(path, content).map_err(|e| {
            crate::config_lib::ConfigError::Validation(format!("Failed to write lock file: {}", e))
        })?;

        Ok(())
    }

    /// Verify lock file integrity
    pub fn verify(lockfile: &OntologyLockfile) -> Result<()> {
        let computed_hashes = Self::compute_hashes(&lockfile.packages);

        for (name, expected_hash) in &lockfile.hashes {
            match computed_hashes.get(name) {
                Some(actual_hash) if actual_hash == expected_hash => continue,
                Some(actual_hash) => {
                    return Err(crate::config_lib::ConfigError::Validation(format!(
                        "Lock file hash mismatch for '{}': expected {}, got {}",
                        name, expected_hash, actual_hash
                    )))
                }
                None => {
                    return Err(crate::config_lib::ConfigError::Validation(format!(
                        "Package '{}' in hash list not found in lock file",
                        name
                    )))
                }
            }
        }

        Ok(())
    }

    /// Get a specific locked package
    pub fn get_package<'a>(
        lockfile: &'a OntologyLockfile, name: &str,
    ) -> Option<&'a LockedPackage> {
        lockfile.packages.get(name)
    }

    /// Get all locked packages in order
    pub fn all_packages(lockfile: &OntologyLockfile) -> Vec<(&str, &LockedPackage)> {
        lockfile
            .packages
            .iter()
            .map(|(k, v)| (k.as_str(), v))
            .collect()
    }

    /// Check if lock file is stale (predates a configuration change)
    pub fn is_stale(lockfile: &OntologyLockfile, since: &str) -> bool {
        lockfile.generated_at.as_str() < since
    }

    /// Compute integrity hashes for all packages
    fn compute_hashes(packages: &BTreeMap<String, LockedPackage>) -> BTreeMap<String, String> {
        let mut hashes = BTreeMap::new();

        for (name, package) in packages {
            // Create a deterministic representation for hashing
            let data = format!(
                "{}:{}:{}:{}",
                name,
                package.version,
                package.namespace.as_deref().unwrap_or(""),
                package.integrity
            );

            // Use SHA256 for integrity
            let hash = format!("{:x}", Self::sha256(&data));
            hashes.insert(name.clone(), hash);
        }

        hashes
    }

    /// Simple SHA256 hash (in real implementation, use sha2 crate)
    fn sha256(data: &str) -> u64 {
        // Simplified for demonstration - use sha2 crate in production
        let mut hash = 0u64;
        for byte in data.as_bytes() {
            hash = hash.wrapping_mul(31).wrapping_add(*byte as u64);
        }
        hash
    }
}

impl OntologyLockfile {
    /// Validate lockfile structure
    pub fn validate(&self) -> Result<()> {
        if self.version != 1 {
            return Err(crate::config_lib::ConfigError::Validation(format!(
                "Unsupported lock file version: {}",
                self.version
            )));
        }

        if self.packages.is_empty() {
            return Err(crate::config_lib::ConfigError::Validation(
                "Lock file contains no packages".to_string(),
            ));
        }

        // Validate each package
        for (name, package) in &self.packages {
            if package.version.is_empty() {
                return Err(crate::config_lib::ConfigError::Validation(format!(
                    "Package '{}' has no version",
                    name
                )));
            }

            if package.integrity.is_empty() {
                return Err(crate::config_lib::ConfigError::Validation(format!(
                    "Package '{}' has no integrity hash",
                    name
                )));
            }
        }

        // Validate composition metadata
        if self.composition.strategy.is_empty() {
            return Err(crate::config_lib::ConfigError::Validation(
                "Composition strategy not specified".to_string(),
            ));
        }

        Ok(())
    }

    /// Get package versions for reproducible build
    pub fn to_version_spec(&self) -> BTreeMap<String, String> {
        self.packages
            .iter()
            .map(|(name, pkg)| (name.clone(), pkg.version.clone()))
            .collect()
    }

    /// Export as environment variables for CI/CD
    pub fn to_env_vars(&self) -> BTreeMap<String, String> {
        let mut vars = BTreeMap::new();

        for (name, package) in &self.packages {
            // Convert package name to valid env var format (replace hyphens with underscores)
            let env_name = name.to_uppercase().replace('-', "_");
            vars.insert(
                format!("GGEN_PACK_{}_VERSION", env_name),
                package.version.clone(),
            );
            vars.insert(
                format!("GGEN_PACK_{}_INTEGRITY", env_name),
                package.integrity.clone(),
            );
        }

        vars.insert(
            "GGEN_LOCK_GENERATED_AT".to_string(),
            self.generated_at.clone(),
        );

        vars
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::BTreeMap;

    fn create_test_lockfile() -> OntologyLockfile {
        let mut packages = BTreeMap::new();

        packages.insert(
            "schema-org".to_string(),
            LockedPackage {
                version: "3.13.0".to_string(),
                resolved: "registry://ggen-marketplace/schema-org@3.13.0".to_string(),
                integrity: "sha256-abc123def456".to_string(),
                location: ".ggen/packages/schema-org/3.13.0".to_string(),
                namespace: Some("https://schema.org/".to_string()),
                classes_count: 788,
                properties_count: 2500,
                dependencies: BTreeMap::new(),
                installed_at: chrono::Utc::now().to_rfc3339(),
            },
        );

        OntologyLockfile {
            version: 1,
            generated_at: chrono::Utc::now().to_rfc3339(),
            generator_version: "3.2.0".to_string(),
            packages,
            composition: CompositionMetadata {
                strategy: "union".to_string(),
                total_classes: 788,
                total_properties: 2500,
                conflicts_resolved: 0,
                validation_status: "valid".to_string(),
            },
            hashes: BTreeMap::new(),
        }
    }

    #[test]
    fn test_lockfile_creation() {
        let lockfile = create_test_lockfile();
        assert!(lockfile.validate().is_ok());
        assert_eq!(lockfile.version, 1);
        assert_eq!(lockfile.packages.len(), 1);
    }

    #[test]
    fn test_lockfile_to_version_spec() {
        let lockfile = create_test_lockfile();
        let spec = lockfile.to_version_spec();

        assert_eq!(spec.get("schema-org"), Some(&"3.13.0".to_string()));
    }

    #[test]
    fn test_lockfile_to_env_vars() {
        let lockfile = create_test_lockfile();
        let vars = lockfile.to_env_vars();

        assert!(vars.contains_key("GGEN_PACK_SCHEMA_ORG_VERSION"));
        assert!(vars.contains_key("GGEN_LOCK_GENERATED_AT"));
    }

    #[test]
    fn test_lockfile_manager_verify() {
        let lockfile = create_test_lockfile();
        // Hash verification would work with actual hashes
        // For now, just test the structure is sound
        assert!(lockfile.validate().is_ok());
    }
}