tl-package 0.3.7

Package manager for ThinkingLanguage
Documentation
use serde::{Deserialize, Serialize};
use std::path::Path;

/// Lock file for pinning resolved dependency versions.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LockFile {
    #[serde(default)]
    pub packages: Vec<LockedPackage>,
}

/// A single locked package entry.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LockedPackage {
    pub name: String,
    pub version: String,
    /// Source descriptor: "git+url#rev", "path+/absolute/path"
    pub source: String,
    /// Whether this is a direct dependency (vs transitive).
    #[serde(default = "default_true")]
    pub direct: bool,
    /// Names of packages this package depends on.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub dependencies: Vec<String>,
}

fn default_true() -> bool {
    true
}

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

    /// Save lock file to disk.
    pub fn save(&self, path: &Path) -> Result<(), String> {
        let content = toml::to_string_pretty(self)
            .map_err(|e| format!("Failed to serialize lock file: {e}"))?;
        let header = "# This file is auto-generated by `tl install`. Do not edit.\n\n";
        std::fs::write(path, format!("{header}{content}"))
            .map_err(|e| format!("Failed to write lock file: {e}"))
    }

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

    /// Remove a package by name. Returns true if it was found and removed.
    pub fn remove(&mut self, name: &str) -> bool {
        let len_before = self.packages.len();
        self.packages.retain(|p| p.name != name);
        self.packages.len() < len_before
    }
}

impl LockedPackage {
    /// Create a new LockedPackage (direct dependency with no transitive deps).
    pub fn new(name: impl Into<String>, version: impl Into<String>, source: String) -> Self {
        LockedPackage {
            name: name.into(),
            version: version.into(),
            source,
            direct: true,
            dependencies: Vec::new(),
        }
    }

    /// Create a source descriptor for a git dependency.
    pub fn git_source(url: &str, rev: &str) -> String {
        format!("git+{url}#{rev}")
    }

    /// Create a source descriptor for a path dependency.
    pub fn path_source(path: &str) -> String {
        format!("path+{path}")
    }

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

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

    /// Extract the path from a path source descriptor.
    pub fn path_value(&self) -> Option<&str> {
        self.source.strip_prefix("path+")
    }

    /// Extract the URL from a git source descriptor.
    pub fn git_url(&self) -> Option<&str> {
        self.source
            .strip_prefix("git+")
            .and_then(|s| s.split('#').next())
    }
}

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

    #[test]
    fn lockfile_round_trip() {
        let dir = TempDir::new().unwrap();
        let lock_path = dir.path().join("tl.lock");

        let lock = LockFile {
            packages: vec![
                LockedPackage::new(
                    "utils",
                    "1.0.0",
                    LockedPackage::path_source("/home/user/utils"),
                ),
                LockedPackage::new(
                    "remote",
                    "2.1.0",
                    LockedPackage::git_source("https://github.com/user/remote.git", "abc123"),
                ),
            ],
        };

        lock.save(&lock_path).unwrap();
        let loaded = LockFile::load(&lock_path).unwrap();
        assert_eq!(loaded.packages.len(), 2);
        assert_eq!(loaded.packages[0].name, "utils");
        assert_eq!(loaded.packages[1].name, "remote");
    }

    #[test]
    fn lockfile_find_by_name() {
        let lock = LockFile {
            packages: vec![
                LockedPackage::new("a", "1.0.0", "path+/a".into()),
                LockedPackage::new("b", "2.0.0", "path+/b".into()),
            ],
        };
        assert!(lock.find("a").is_some());
        assert!(lock.find("c").is_none());
    }

    #[test]
    fn lockfile_load_nonexistent() {
        let lock = LockFile::load(Path::new("/nonexistent/tl.lock")).unwrap();
        assert!(lock.packages.is_empty());
    }

    #[test]
    fn lockfile_remove() {
        let mut lock = LockFile {
            packages: vec![
                LockedPackage::new("a", "1.0.0", "path+/a".into()),
                LockedPackage::new("b", "2.0.0", "path+/b".into()),
            ],
        };
        assert!(lock.remove("a"));
        assert_eq!(lock.packages.len(), 1);
        assert!(!lock.remove("a"));
    }

    #[test]
    fn locked_package_source_helpers() {
        let pkg = LockedPackage::new(
            "test",
            "1.0.0",
            LockedPackage::git_source("https://example.com/repo.git", "deadbeef"),
        );
        assert!(pkg.is_git());
        assert!(!pkg.is_path());
        assert_eq!(pkg.git_url(), Some("https://example.com/repo.git"));

        let path_pkg = LockedPackage::new(
            "local",
            "0.1.0",
            LockedPackage::path_source("/home/user/local"),
        );
        assert!(path_pkg.is_path());
        assert_eq!(path_pkg.path_value(), Some("/home/user/local"));
    }

    #[test]
    fn lockfile_backward_compat() {
        // Old format without direct/dependencies fields should deserialize correctly
        let toml_str = r#"
[[packages]]
name = "oldpkg"
version = "1.0.0"
source = "path+/old"
"#;
        let lock: LockFile = toml::from_str(toml_str).unwrap();
        assert_eq!(lock.packages.len(), 1);
        assert!(lock.packages[0].direct); // defaults to true
        assert!(lock.packages[0].dependencies.is_empty()); // defaults to empty
    }

    #[test]
    fn lockfile_round_trip_new_fields() {
        let dir = TempDir::new().unwrap();
        let lock_path = dir.path().join("tl.lock");

        let mut pkg = LockedPackage::new("transitive", "1.0.0", "path+/t".into());
        pkg.direct = false;
        pkg.dependencies = vec!["sub-dep".into()];

        let lock = LockFile {
            packages: vec![pkg],
        };
        lock.save(&lock_path).unwrap();
        let loaded = LockFile::load(&lock_path).unwrap();
        assert_eq!(loaded.packages[0].direct, false);
        assert_eq!(loaded.packages[0].dependencies, vec!["sub-dep".to_string()]);
    }
}