juglans 0.2.15

Compiler and runtime for Juglans Workflow Language
// src/registry/lock.rs
//
// Lock file (jgpackage.lock) — pins exact versions for reproducible installs.
//
// Format:
// ```toml
// # Auto-generated by juglans. Do not edit manually.
//
// [[package]]
// name = "sqlite-tools"
// version = "1.2.0"
// checksum = "sha256:abc123..."
//
// [[package]]
// name = "http-client"
// version = "2.0.1"
// checksum = "sha256:def456..."
// ```

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};

const LOCK_FILE_NAME: &str = "jgpackage.lock";
const LOCK_HEADER: &str = "# Auto-generated by juglans. Do not edit manually.\n\n";

/// A single locked package entry
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LockedPackage {
    pub name: String,
    pub version: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub checksum: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub dependencies: Vec<String>,
}

/// The full lock file contents
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LockFile {
    #[serde(default, rename = "package")]
    pub packages: Vec<LockedPackage>,
}

impl LockFile {
    /// Load from a jgpackage.lock file. Returns empty LockFile if file doesn't exist.
    pub fn load(project_dir: &Path) -> Result<Self> {
        let path = project_dir.join(LOCK_FILE_NAME);
        if !path.exists() {
            return Ok(Self::default());
        }
        let content = fs::read_to_string(&path)
            .with_context(|| format!("Failed to read {}", path.display()))?;
        toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))
    }

    /// Save lock file to project directory
    pub fn save(&self, project_dir: &Path) -> Result<PathBuf> {
        let path = project_dir.join(LOCK_FILE_NAME);
        let toml_str = toml::to_string_pretty(self).context("Failed to serialize lock file")?;
        let content = format!("{}{}", LOCK_HEADER, toml_str);
        fs::write(&path, &content)
            .with_context(|| format!("Failed to write {}", path.display()))?;
        Ok(path)
    }

    /// Look up a locked version by package name
    pub fn _get(&self, name: &str) -> Option<&LockedPackage> {
        self.packages.iter().find(|p| p.name == name)
    }

    /// Insert or update a package in the lock file.
    /// Maintains sorted order by name for deterministic output.
    pub fn upsert(&mut self, pkg: LockedPackage) {
        if let Some(existing) = self.packages.iter_mut().find(|p| p.name == pkg.name) {
            *existing = pkg;
        } else {
            self.packages.push(pkg);
        }
        self.packages.sort_by(|a, b| a.name.cmp(&b.name));
    }

    /// Remove a package by name
    pub fn remove(&mut self, name: &str) {
        self.packages.retain(|p| p.name != name);
    }

    /// Convert to a lookup map for quick version resolution
    pub fn to_map(&self) -> BTreeMap<String, String> {
        self.packages
            .iter()
            .map(|p| (p.name.clone(), p.version.clone()))
            .collect()
    }
}

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

    #[test]
    fn test_lock_file_roundtrip() {
        let mut lock = LockFile::default();
        lock.upsert(LockedPackage {
            name: "sqlite-tools".to_string(),
            version: "1.2.0".to_string(),
            checksum: Some("sha256:abc123".to_string()),
            dependencies: vec![],
        });
        lock.upsert(LockedPackage {
            name: "http-client".to_string(),
            version: "2.0.1".to_string(),
            checksum: None,
            dependencies: vec!["json-utils@^1.0".to_string()],
        });

        let dir = std::env::temp_dir().join("juglans_test_lock");
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).unwrap();

        lock.save(&dir).unwrap();

        let loaded = LockFile::load(&dir).unwrap();
        assert_eq!(loaded.packages.len(), 2);
        // Sorted by name
        assert_eq!(loaded.packages[0].name, "http-client");
        assert_eq!(loaded.packages[1].name, "sqlite-tools");
        assert_eq!(loaded.packages[1].version, "1.2.0");
        assert_eq!(loaded.packages[0].dependencies, vec!["json-utils@^1.0"]);

        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn test_lock_file_empty() {
        let dir = std::env::temp_dir().join("juglans_test_lock_empty");
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).unwrap();

        let lock = LockFile::load(&dir).unwrap();
        assert!(lock.packages.is_empty());

        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn test_lock_upsert_and_remove() {
        let mut lock = LockFile::default();
        lock.upsert(LockedPackage {
            name: "pkg-a".to_string(),
            version: "1.0.0".to_string(),
            checksum: None,
            dependencies: vec![],
        });
        assert_eq!(lock.packages.len(), 1);

        // Update
        lock.upsert(LockedPackage {
            name: "pkg-a".to_string(),
            version: "2.0.0".to_string(),
            checksum: None,
            dependencies: vec![],
        });
        assert_eq!(lock.packages.len(), 1);
        assert_eq!(lock.packages[0].version, "2.0.0");

        // Remove
        lock.remove("pkg-a");
        assert!(lock.packages.is_empty());
    }

    #[test]
    fn test_lock_to_map() {
        let mut lock = LockFile::default();
        lock.upsert(LockedPackage {
            name: "a".to_string(),
            version: "1.0.0".to_string(),
            checksum: None,
            dependencies: vec![],
        });
        lock.upsert(LockedPackage {
            name: "b".to_string(),
            version: "2.0.0".to_string(),
            checksum: None,
            dependencies: vec![],
        });
        let map = lock.to_map();
        assert_eq!(map.get("a").unwrap(), "1.0.0");
        assert_eq!(map.get("b").unwrap(), "2.0.0");
    }
}