bock-pkg 0.1.0

Package management for Bock projects, including dependency resolution and lockfiles
Documentation
//! Lockfile (`bock.lock`) generation and reading.
//!
//! The lockfile records the exact resolved versions of all dependencies,
//! enabling reproducible builds.

use std::collections::BTreeMap;
use std::path::Path;

use semver::Version;
use serde::{Deserialize, Serialize};

use crate::error::PkgError;

/// A parsed `bock.lock` lockfile.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Lockfile {
    /// Lockfile format version.
    #[serde(default = "default_version")]
    pub version: u32,

    /// Locked package entries, keyed by package name.
    #[serde(default, rename = "package")]
    pub packages: Vec<LockedPackage>,
}

fn default_version() -> u32 {
    1
}

/// A single locked package entry.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LockedPackage {
    /// Package name.
    pub name: String,

    /// Exact resolved version.
    pub version: String,

    /// Source registry or path.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub source: Option<String>,

    /// Content checksum.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub checksum: Option<String>,

    /// Direct dependencies of this package.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub dependencies: BTreeMap<String, String>,
}

impl Lockfile {
    /// Create a new lockfile from resolved dependencies.
    #[must_use]
    pub fn from_resolved(resolved: &BTreeMap<String, Version>) -> Self {
        let packages = resolved
            .iter()
            .map(|(name, version)| LockedPackage {
                name: name.clone(),
                version: version.to_string(),
                source: None,
                checksum: None,
                dependencies: BTreeMap::new(),
            })
            .collect();

        Lockfile {
            version: 1,
            packages,
        }
    }

    /// Parse a lockfile from a TOML string.
    pub fn parse(s: &str) -> Result<Self, PkgError> {
        toml::from_str(s).map_err(|e| PkgError::LockfileParse(e.to_string()))
    }

    /// Read and parse a lockfile from a file path.
    pub fn from_file(path: &Path) -> Result<Self, PkgError> {
        let content = std::fs::read_to_string(path).map_err(|e| PkgError::Io(e.to_string()))?;
        Self::parse(&content)
    }

    /// Serialize the lockfile to a TOML string.
    pub fn to_toml_string(&self) -> Result<String, PkgError> {
        let mut output = String::new();
        output.push_str("# This file is automatically generated by `bock pkg`.\n");
        output.push_str("# Do not edit manually.\n\n");
        output.push_str(&format!("version = {}\n", self.version));

        for pkg in &self.packages {
            output.push('\n');
            output.push_str("[[package]]\n");
            output.push_str(&format!("name = \"{}\"\n", pkg.name));
            output.push_str(&format!("version = \"{}\"\n", pkg.version));
            if let Some(source) = &pkg.source {
                output.push_str(&format!("source = \"{source}\"\n"));
            }
            if let Some(checksum) = &pkg.checksum {
                output.push_str(&format!("checksum = \"{checksum}\"\n"));
            }
            if !pkg.dependencies.is_empty() {
                output.push_str("\n[package.dependencies]\n");
                for (name, ver) in &pkg.dependencies {
                    output.push_str(&format!("{name} = \"{ver}\"\n"));
                }
            }
        }

        Ok(output)
    }

    /// Write the lockfile to a file.
    pub fn write_to_file(&self, path: &Path) -> Result<(), PkgError> {
        let content = self.to_toml_string()?;
        std::fs::write(path, content).map_err(|e| PkgError::Io(e.to_string()))
    }

    /// Get the locked version of a specific package.
    #[must_use]
    pub fn get_version(&self, name: &str) -> Option<&str> {
        self.packages
            .iter()
            .find(|p| p.name == name)
            .map(|p| p.version.as_str())
    }

    /// Convert locked packages back to a resolved dependency map.
    pub fn to_resolved(&self) -> Result<BTreeMap<String, Version>, PkgError> {
        let mut result = BTreeMap::new();
        for pkg in &self.packages {
            let ver = crate::version::parse_version(&pkg.version)?;
            result.insert(pkg.name.clone(), ver);
        }
        Ok(result)
    }
}

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

    #[test]
    fn create_from_resolved() {
        let mut resolved = BTreeMap::new();
        resolved.insert("foo".to_string(), Version::new(1, 2, 3));
        resolved.insert("bar".to_string(), Version::new(0, 5, 0));

        let lockfile = Lockfile::from_resolved(&resolved);
        assert_eq!(lockfile.packages.len(), 2);
        assert_eq!(lockfile.get_version("foo"), Some("1.2.3"));
        assert_eq!(lockfile.get_version("bar"), Some("0.5.0"));
    }

    #[test]
    fn roundtrip_serialize() {
        let mut resolved = BTreeMap::new();
        resolved.insert("dep-a".to_string(), Version::new(1, 0, 0));
        resolved.insert("dep-b".to_string(), Version::new(2, 3, 4));

        let lockfile = Lockfile::from_resolved(&resolved);
        let toml_str = lockfile.to_toml_string().unwrap();

        // Should be parseable back
        let reparsed = Lockfile::parse(&toml_str).unwrap();
        assert_eq!(reparsed.packages.len(), 2);
        assert_eq!(reparsed.get_version("dep-a"), Some("1.0.0"));
        assert_eq!(reparsed.get_version("dep-b"), Some("2.3.4"));
    }

    #[test]
    fn to_resolved_map() {
        let lockfile = Lockfile {
            version: 1,
            packages: vec![LockedPackage {
                name: "x".into(),
                version: "1.0.0".into(),
                source: None,
                checksum: None,
                dependencies: BTreeMap::new(),
            }],
        };

        let resolved = lockfile.to_resolved().unwrap();
        assert_eq!(resolved["x"], Version::new(1, 0, 0));
    }

    #[test]
    fn file_roundtrip() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("bock.lock");

        let mut resolved = BTreeMap::new();
        resolved.insert("pkg-a".to_string(), Version::new(3, 1, 4));

        let lockfile = Lockfile::from_resolved(&resolved);
        lockfile.write_to_file(&path).unwrap();

        let loaded = Lockfile::from_file(&path).unwrap();
        assert_eq!(loaded.get_version("pkg-a"), Some("3.1.4"));
    }
}