sage-package 2.1.0

Package manager for Sage - Git-first dependency management
Documentation
//! Lock file (grove.lock) management.

use crate::error::PackageError;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;

/// The lock file format for grove.lock.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LockFile {
    /// Schema version for future compatibility.
    #[serde(default = "default_version")]
    pub version: u32,
    /// All locked packages (including transitive dependencies).
    #[serde(default)]
    pub packages: Vec<LockedPackage>,
}

fn default_version() -> u32 {
    1
}

/// A locked package entry.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LockedPackage {
    /// Package name (as declared in grove.toml).
    pub name: String,
    /// Package version from its grove.toml.
    pub version: String,
    /// Git repository URL (None for path dependencies).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub git: Option<String>,
    /// Pinned full SHA (None for path dependencies).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub rev: Option<String>,
    /// Local path (for path dependencies).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub path: Option<String>,
    /// List of package names this depends on (for ordering).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub dependencies: Vec<String>,
}

impl LockedPackage {
    /// Create a new locked package for a git dependency.
    pub fn git(
        name: String,
        version: String,
        git: String,
        rev: String,
        dependencies: Vec<String>,
    ) -> Self {
        Self {
            name,
            version,
            git: Some(git),
            rev: Some(rev),
            path: None,
            dependencies,
        }
    }

    /// Create a new locked package for a path dependency.
    pub fn path(name: String, version: String, path: String, dependencies: Vec<String>) -> Self {
        Self {
            name,
            version,
            git: None,
            rev: None,
            path: Some(path),
            dependencies,
        }
    }

    /// Check if this is a path dependency.
    pub fn is_path(&self) -> bool {
        self.path.is_some()
    }

    /// Check if this is a git dependency.
    pub fn is_git(&self) -> bool {
        self.git.is_some()
    }
}

impl LockFile {
    /// Load a lock file from disk.
    pub fn load(path: &Path) -> Result<Self, PackageError> {
        let contents = std::fs::read_to_string(path).map_err(|e| PackageError::IoError {
            message: format!("failed to read {}", path.display()),
            source: e,
        })?;

        toml::from_str(&contents).map_err(|e| PackageError::InvalidLockFile { source: e })
    }

    /// Save the lock file to disk.
    pub fn save(&self, path: &Path) -> Result<(), PackageError> {
        let header = "# This file is auto-generated by Grove. Do not edit manually.\n\n";
        let contents = toml::to_string_pretty(self).map_err(|e| PackageError::IoError {
            message: format!("failed to serialize lock file: {e}"),
            source: std::io::Error::new(std::io::ErrorKind::InvalidData, e),
        })?;

        std::fs::write(path, format!("{header}{contents}")).map_err(|e| PackageError::IoError {
            message: format!("failed to write {}", path.display()),
            source: e,
        })?;

        Ok(())
    }

    /// Check if the lock file is empty.
    pub fn is_empty(&self) -> bool {
        self.packages.is_empty()
    }

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

    /// Build a map of package name to locked package.
    pub fn package_map(&self) -> HashMap<&str, &LockedPackage> {
        self.packages.iter().map(|p| (p.name.as_str(), p)).collect()
    }

    /// Check if the lock file matches the given dependencies.
    pub fn matches_dependencies(&self, deps: &HashMap<String, crate::DependencySpec>) -> bool {
        use crate::DependencySpec;

        // All deps must be in lock file with matching source
        for (name, spec) in deps {
            match self.find(name) {
                Some(locked) => match spec {
                    DependencySpec::Git(g) => {
                        // Git dep must match git URL
                        if locked.git.as_ref() != Some(&g.git) {
                            return false;
                        }
                    }
                    DependencySpec::Path(p) => {
                        // Path dep must match path
                        if locked.path.as_ref() != Some(&p.path) {
                            return false;
                        }
                    }
                },
                None => return false,
            }
        }
        true
    }

    /// Return packages in dependency order (dependencies first).
    pub fn in_dependency_order(&self) -> Vec<&LockedPackage> {
        // Simple topological sort
        let mut result = Vec::new();
        let mut visited = std::collections::HashSet::new();
        let pkg_map: HashMap<&str, &LockedPackage> = self.package_map();

        fn visit<'a>(
            pkg: &'a LockedPackage,
            pkg_map: &HashMap<&str, &'a LockedPackage>,
            visited: &mut std::collections::HashSet<&'a str>,
            result: &mut Vec<&'a LockedPackage>,
        ) {
            if visited.contains(pkg.name.as_str()) {
                return;
            }
            visited.insert(&pkg.name);

            for dep_name in &pkg.dependencies {
                if let Some(dep) = pkg_map.get(dep_name.as_str()) {
                    visit(dep, pkg_map, visited, result);
                }
            }
            result.push(pkg);
        }

        for pkg in &self.packages {
            visit(pkg, &pkg_map, &mut visited, &mut result);
        }

        result
    }
}

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

    #[test]
    fn serialize_lock_file() {
        let lock = LockFile {
            version: 1,
            packages: vec![
                LockedPackage::git(
                    "foo".to_string(),
                    "1.0.0".to_string(),
                    "https://github.com/example/foo".to_string(),
                    "abc123def456".to_string(),
                    vec![],
                ),
                LockedPackage::git(
                    "bar".to_string(),
                    "2.0.0".to_string(),
                    "https://github.com/example/bar".to_string(),
                    "789xyz".to_string(),
                    vec!["foo".to_string()],
                ),
            ],
        };

        let serialized = toml::to_string_pretty(&lock).unwrap();
        assert!(serialized.contains("name = \"foo\""));
        assert!(serialized.contains("name = \"bar\""));
        assert!(serialized.contains("dependencies = [\"foo\"]"));
    }

    #[test]
    fn serialize_path_dependency() {
        let lock = LockFile {
            version: 1,
            packages: vec![LockedPackage::path(
                "local-lib".to_string(),
                "0.1.0".to_string(),
                "../my-local-lib".to_string(),
                vec![],
            )],
        };

        let serialized = toml::to_string_pretty(&lock).unwrap();
        assert!(serialized.contains("name = \"local-lib\""));
        assert!(serialized.contains("path = \"../my-local-lib\""));
        assert!(!serialized.contains("git ="));
        assert!(!serialized.contains("rev ="));
    }

    #[test]
    fn deserialize_lock_file() {
        let toml_str = r#"
version = 1

[[packages]]
name = "foo"
version = "1.0.0"
git = "https://github.com/example/foo"
rev = "abc123"

[[packages]]
name = "bar"
version = "2.0.0"
git = "https://github.com/example/bar"
rev = "def456"
dependencies = ["foo"]
"#;

        let lock: LockFile = toml::from_str(toml_str).unwrap();
        assert_eq!(lock.packages.len(), 2);
        assert_eq!(lock.packages[0].name, "foo");
        assert_eq!(lock.packages[1].dependencies, vec!["foo"]);
    }

    #[test]
    fn deserialize_path_dependency() {
        let toml_str = r#"
version = 1

[[packages]]
name = "local"
version = "0.1.0"
path = "../local-lib"
"#;

        let lock: LockFile = toml::from_str(toml_str).unwrap();
        assert_eq!(lock.packages.len(), 1);
        assert!(lock.packages[0].is_path());
        assert_eq!(lock.packages[0].path, Some("../local-lib".to_string()));
    }

    #[test]
    fn find_package() {
        let lock = LockFile {
            version: 1,
            packages: vec![LockedPackage::git(
                "test".to_string(),
                "1.0.0".to_string(),
                "https://example.com/test".to_string(),
                "abc".to_string(),
                vec![],
            )],
        };

        assert!(lock.find("test").is_some());
        assert!(lock.find("nonexistent").is_none());
    }

    #[test]
    fn dependency_order() {
        let lock = LockFile {
            version: 1,
            packages: vec![
                LockedPackage::git(
                    "c".to_string(),
                    "1.0.0".to_string(),
                    "https://example.com/c".to_string(),
                    "ccc".to_string(),
                    vec!["a".to_string(), "b".to_string()],
                ),
                LockedPackage::git(
                    "a".to_string(),
                    "1.0.0".to_string(),
                    "https://example.com/a".to_string(),
                    "aaa".to_string(),
                    vec![],
                ),
                LockedPackage::git(
                    "b".to_string(),
                    "1.0.0".to_string(),
                    "https://example.com/b".to_string(),
                    "bbb".to_string(),
                    vec!["a".to_string()],
                ),
            ],
        };

        let ordered = lock.in_dependency_order();
        let names: Vec<&str> = ordered.iter().map(|p| p.name.as_str()).collect();

        // a must come before b and c, b must come before c
        let a_pos = names.iter().position(|&n| n == "a").unwrap();
        let b_pos = names.iter().position(|&n| n == "b").unwrap();
        let c_pos = names.iter().position(|&n| n == "c").unwrap();

        assert!(a_pos < b_pos);
        assert!(a_pos < c_pos);
        assert!(b_pos < c_pos);
    }

    #[test]
    fn locked_package_helpers() {
        let git_pkg = LockedPackage::git(
            "foo".to_string(),
            "1.0.0".to_string(),
            "https://example.com".to_string(),
            "abc123".to_string(),
            vec![],
        );
        assert!(git_pkg.is_git());
        assert!(!git_pkg.is_path());

        let path_pkg = LockedPackage::path(
            "bar".to_string(),
            "0.1.0".to_string(),
            "../bar".to_string(),
            vec![],
        );
        assert!(path_pkg.is_path());
        assert!(!path_pkg.is_git());
    }
}